From 7bc52101be8c53bc3cc5e9bdaea42dc42a48627e Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Tue, 23 Jun 2026 22:22:40 +0200 Subject: [PATCH] feat(config): support config.local.toml machine-local overlay Load an optional config.local.toml next to the main config and merge it on top before validation and recipe processing. The overlay path derives from the resolved config path (config.toml -> config.local.toml, honoring RALPH_CONFIG), and is always optional. Merge is local-wins (not conflict-detected like recipe merging): scalars override when set, map keys merge per-key, slices replace when non-empty. - internal/config/local.go: path derivation + mergeLocalConfig - internal/config/load.go: overlay hook in LoadConfigWithHost - ralph init: gitignore config.local.toml in the dotfiles repo - ralph doctor: report overlay loaded / not found under a Configuration phase - docs/configuration.md: document the overlay and merge semantics Closes MAD-80 --- cmd/ralph/commands/cmd_doctor.go | 23 ++++ cmd/ralph/commands/cmd_init.go | 45 ++++++++ cmd/ralph/commands/cmd_init_test.go | 80 +++++++++++++ docs/configuration.md | 40 +++++++ internal/config/load.go | 7 ++ internal/config/local.go | 134 ++++++++++++++++++++++ internal/config/local_test.go | 167 ++++++++++++++++++++++++++++ 7 files changed, 496 insertions(+) create mode 100644 cmd/ralph/commands/cmd_init_test.go create mode 100644 internal/config/local.go create mode 100644 internal/config/local_test.go diff --git a/cmd/ralph/commands/cmd_doctor.go b/cmd/ralph/commands/cmd_doctor.go index 56a3f04..e1b1164 100644 --- a/cmd/ralph/commands/cmd_doctor.go +++ b/cmd/ralph/commands/cmd_doctor.go @@ -45,6 +45,7 @@ var doctorCmd = &cobra.Command{ return &ExitError{Code: 1} } + checkConfig(rpt) checkDotfiles(rpt, cfg) checkDirectories(rpt, cfg) checkRepositories(rpt, cfg) @@ -61,6 +62,28 @@ var doctorCmd = &cobra.Command{ }, } +// checkConfig reports on config file resolution: the active config path and +// whether the optional config.local.toml overlay was found. +func checkConfig(rpt *report.Report) { + phase := rpt.AddPhase("Configuration") + + configPath, err := config.GetDefaultConfigPath() + if err != nil { + phase.AddResult("config path", "", report.StatusFail, fmt.Sprintf("%v", err), err) + return + } + phase.AddResult("config.toml", "", report.StatusOK, configPath, nil) + + localPath := config.LocalConfigPath(configPath) + if _, err := os.Stat(localPath); err == nil { + phase.AddResult("config.local.toml", "", report.StatusOK, "loaded: "+localPath, nil) + } else if os.IsNotExist(err) { + phase.AddResult("config.local.toml", "", report.StatusSkip, "not found (optional)", nil) + } else { + phase.AddResult("config.local.toml", "", report.StatusWarn, fmt.Sprintf("%v", err), err) + } +} + func checkDotfiles(rpt *report.Report, cfg *config.Config) { if len(cfg.Dotfiles) == 0 { return diff --git a/cmd/ralph/commands/cmd_init.go b/cmd/ralph/commands/cmd_init.go index 3858153..fde0cda 100644 --- a/cmd/ralph/commands/cmd_init.go +++ b/cmd/ralph/commands/cmd_init.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" // "io/fs" // Unused import @@ -123,6 +124,14 @@ var initCmd = &cobra.Command{ } color.Green("Default configuration file created at %s", defaultConfigPath) + // Best-effort: keep the optional machine-local overlay out of version + // control by gitignoring it in the dotfiles repo. Never fail init on this. + if added, err := ensureGitignored(expandedRepoPath, config.LocalConfigFileName); err != nil { + color.Yellow("Could not update .gitignore in %s: %v", expandedRepoPath, err) + } else if added { + color.Green("Added '%s' to %s", config.LocalConfigFileName, filepath.Join(expandedRepoPath, ".gitignore")) + } + fmt.Println("\n" + color.New(color.FgCyan, color.Bold).Sprint("🎉 Next Steps:")) fmt.Printf( "1. %s your dotfiles repository at '%s'.\n", @@ -156,6 +165,42 @@ var initCmd = &cobra.Command{ }, } +// ensureGitignored adds entry to repoPath/.gitignore unless it is already +// ignored. It is a no-op (added=false, err=nil) when repoPath is not an +// existing directory, so it is safe to call before the repo is populated. The +// .gitignore is created if missing. A trailing newline is preserved. +func ensureGitignored(repoPath, entry string) (added bool, err error) { + info, statErr := os.Stat(repoPath) + if statErr != nil || !info.IsDir() { + return false, nil //nolint:nilerr // missing repo dir is intentionally not an error here + } + + gitignorePath := filepath.Join(repoPath, ".gitignore") + content, readErr := os.ReadFile(gitignorePath) + if readErr != nil && !os.IsNotExist(readErr) { + return false, fmt.Errorf("reading %s: %w", gitignorePath, readErr) + } + + for line := range strings.SplitSeq(string(content), "\n") { + if strings.TrimSpace(line) == entry { + return false, nil + } + } + + var b strings.Builder + b.Write(content) + if len(content) > 0 && !bytes.HasSuffix(content, []byte("\n")) { + b.WriteString("\n") + } + b.WriteString(entry) + b.WriteString("\n") + + if err := os.WriteFile(gitignorePath, []byte(b.String()), 0o644); err != nil { + return false, fmt.Errorf("writing %s: %w", gitignorePath, err) + } + return true, nil +} + func init() { rootCmd.AddCommand(initCmd) } diff --git a/cmd/ralph/commands/cmd_init_test.go b/cmd/ralph/commands/cmd_init_test.go new file mode 100644 index 0000000..1e7a8be --- /dev/null +++ b/cmd/ralph/commands/cmd_init_test.go @@ -0,0 +1,80 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureGitignored(t *testing.T) { + t.Run("creates .gitignore when missing", func(t *testing.T) { + dir := t.TempDir() + added, err := ensureGitignored(dir, "config.local.toml") + if err != nil { + t.Fatalf("ensureGitignored() error = %v", err) + } + if !added { + t.Fatal("expected added=true") + } + got := readFile(t, filepath.Join(dir, ".gitignore")) + if got != "config.local.toml\n" { + t.Errorf(".gitignore = %q, want %q", got, "config.local.toml\n") + } + }) + + t.Run("idempotent when entry already present", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".gitignore"), "node_modules\nconfig.local.toml\n") + + added, err := ensureGitignored(dir, "config.local.toml") + if err != nil { + t.Fatalf("ensureGitignored() error = %v", err) + } + if added { + t.Error("expected added=false when entry already present") + } + got := readFile(t, filepath.Join(dir, ".gitignore")) + if got != "node_modules\nconfig.local.toml\n" { + t.Errorf(".gitignore changed: %q", got) + } + }) + + t.Run("appends a newline when file lacks trailing newline", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".gitignore"), "node_modules") + + if _, err := ensureGitignored(dir, "config.local.toml"); err != nil { + t.Fatalf("ensureGitignored() error = %v", err) + } + got := readFile(t, filepath.Join(dir, ".gitignore")) + if got != "node_modules\nconfig.local.toml\n" { + t.Errorf(".gitignore = %q, want %q", got, "node_modules\nconfig.local.toml\n") + } + }) + + t.Run("no-op when repo dir does not exist", func(t *testing.T) { + added, err := ensureGitignored(filepath.Join(t.TempDir(), "nope"), "config.local.toml") + if err != nil { + t.Fatalf("ensureGitignored() error = %v", err) + } + if added { + t.Error("expected added=false for missing repo dir") + } + }) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 5eef40e..e6e6e85 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,8 +16,48 @@ If `$XDG_CONFIG_HOME` is not set, it defaults to: ~/.config/ralph/config.toml ``` +Set `RALPH_CONFIG` to point at a specific config file, which takes precedence over the XDG path. If `RALPH_CONFIG` names a file that doesn't exist, ralph errors instead of falling back to the default. + Run `ralph init` to create a starter config interactively. See [commands](commands.md) for details. +## Local Overlay (`config.local.toml`) + +Ralph optionally loads a second file next to the main config and merges it on top. The overlay is named by inserting `.local` before the extension of the resolved config path: + +| Main config | Overlay | +|-------------|---------| +| `~/.config/ralph/config.toml` | `~/.config/ralph/config.local.toml` | +| `RALPH_CONFIG=/path/myralph.toml` | `/path/myralph.local.toml` | + +The overlay is always optional — a missing file is not an error. Keep it out of version control for machine-specific settings (host toggles, local paths, secrets). `ralph init` adds `config.local.toml` to your dotfiles repo's `.gitignore`, and `ralph doctor` reports whether the overlay was found. + +### Merge semantics + +The overlay wins over the main config, per field type: + +| Field type | Rule | Examples | +|------------|------|----------| +| Scalars | Overlay overrides when set to a non-zero value | `dotfiles_repo_path`, `packages_dir`, `shell.name` | +| Maps | Keys are merged; overlay wins per key | `[dotfiles]`, `[packages]`, `[recipes_config.overrides]`, `[shell.aliases]` | +| Slices | Overlay replaces the whole list when non-empty | `[[tools]]`, `[[recipes]]`, `hooks.pre_apply` | + +Boolean scalars can only be turned on from the overlay — a `false` reads as unset and won't override a `true` in the main config. + +### Example + +Instead of committing host filters to the shared config: + +```toml +# config.local.toml on the machine named "yesyes" +[recipes_config.overrides.apple-dev] +enable = true + +[recipes_config.overrides.pi] +enable = false +``` + +This enables `apple-dev` and disables `pi` on this machine only, without editing the committed `config.toml`. + ## Top-Level Fields | Field | Type | Required | Default | Description | diff --git a/internal/config/load.go b/internal/config/load.go index e59c3ed..16d5d66 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -41,6 +41,13 @@ func LoadConfigWithHost(host string) (*Config, error) { return nil, fmt.Errorf("failed to decode config file %s: %w", configPath, err) } + // Overlay the optional, git-ignored config.local.toml (machine-local + // overrides) before any validation or recipe processing, so the merged + // result is validated as one config and recipe overrides set locally apply. + if _, err := loadLocalOverlay(&cfg, configPath); err != nil { + return nil, fmt.Errorf("failed to load local config overlay: %w", err) + } + // Validate the base config first if err := ValidateConfig(&cfg); err != nil { return nil, fmt.Errorf("configuration validation failed: %w", err) diff --git a/internal/config/local.go b/internal/config/local.go new file mode 100644 index 0000000..328e71e --- /dev/null +++ b/internal/config/local.go @@ -0,0 +1,134 @@ +package config + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// LocalConfigSuffix is inserted before the extension of the main config file to +// derive the optional, git-ignored overlay path: config.toml -> config.local.toml. +const LocalConfigSuffix = ".local" + +// LocalConfigFileName is the overlay file name derived from the default +// config.toml. Used by `ralph init` for the .gitignore entry. +const LocalConfigFileName = "config" + LocalConfigSuffix + ".toml" + +// LocalConfigPath derives the overlay path from the resolved main config path. +// It keeps the directory and base name and inserts ".local" before the +// extension, mirroring mise's config.toml -> config.local.toml convention. +// A main path with no extension gets ".local.toml" appended. +func LocalConfigPath(mainConfigPath string) string { + dir := filepath.Dir(mainConfigPath) + base := filepath.Base(mainConfigPath) + ext := filepath.Ext(base) + if ext == "" { + return filepath.Join(dir, base+LocalConfigSuffix+".toml") + } + stem := strings.TrimSuffix(base, ext) + return filepath.Join(dir, stem+LocalConfigSuffix+ext) +} + +// loadLocalOverlay decodes the optional config.local.toml sitting next to the +// main config and merges it onto cfg. The overlay is always optional: a missing +// file is not an error. It returns whether an overlay was found and merged. +// +// Merge semantics (local wins, never conflict-detected like recipes): +// - scalar fields: local overrides when set to a non-zero value +// - map fields: keys are merged, local wins per key +// - slice fields: local replaces the whole slice when non-empty +func loadLocalOverlay(cfg *Config, mainConfigPath string) (bool, error) { + localPath := LocalConfigPath(mainConfigPath) + + if _, err := os.Stat(localPath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to stat local config %s: %w", localPath, err) + } + + var local Config + if _, err := toml.DecodeFile(localPath, &local); err != nil { + return false, fmt.Errorf("failed to decode local config %s: %w", localPath, err) + } + + mergeLocalConfig(cfg, &local) + return true, nil +} + +// mergeLocalConfig overlays local onto base in place with local-wins semantics. +func mergeLocalConfig(base, local *Config) { + // Scalars: local overrides when set. + if local.DotfilesRepoPath != "" { + base.DotfilesRepoPath = local.DotfilesRepoPath + } + if local.PackagesDir != "" { + base.PackagesDir = local.PackagesDir + } + if local.Shell.Name != "" { + base.Shell.Name = local.Shell.Name + } + if local.RecipesConfig.AutoDiscover { + base.RecipesConfig.AutoDiscover = true + } + if local.RecipesConfig.AutoCleanup { + base.RecipesConfig.AutoCleanup = true + } + if local.RecipesConfig.Dir != "" { + base.RecipesConfig.Dir = local.RecipesConfig.Dir + } + + // Maps: merge keys, local wins per key. + mergeLocalMap(&base.Dotfiles, local.Dotfiles) + mergeLocalMap(&base.DirsMirror, local.DirsMirror) + mergeLocalMap(&base.Directories, local.Directories) + mergeLocalMap(&base.Repos, local.Repos) + mergeLocalMap(&base.Packages, local.Packages) + mergeLocalMap(&base.TemplateVariables, local.TemplateVariables) + mergeLocalMap(&base.Shell.Aliases, local.Shell.Aliases) + mergeLocalMap(&base.Shell.Functions, local.Shell.Functions) + mergeLocalMap(&base.Shell.Env, local.Shell.Env) + mergeLocalMap(&base.Hooks.PreLink, local.Hooks.PreLink) + mergeLocalMap(&base.Hooks.PostLink, local.Hooks.PostLink) + mergeLocalMap(&base.Hooks.Builds, local.Hooks.Builds) + mergeLocalMap(&base.RecipesConfig.Overrides, local.RecipesConfig.Overrides) + + // Slices: local replaces the whole slice when set. + if len(local.Tools) > 0 { + base.Tools = local.Tools + } + if len(local.Recipes) > 0 { + base.Recipes = local.Recipes + } + if len(local.Hooks.PreApply) > 0 { + base.Hooks.PreApply = local.Hooks.PreApply + } + if len(local.Hooks.PostApply) > 0 { + base.Hooks.PostApply = local.Hooks.PostApply + } + if len(local.Hooks.PreUninstall) > 0 { + base.Hooks.PreUninstall = local.Hooks.PreUninstall + } + if len(local.Hooks.PostUninstall) > 0 { + base.Hooks.PostUninstall = local.Hooks.PostUninstall + } + if len(local.RecipesConfig.Exclude) > 0 { + base.RecipesConfig.Exclude = local.RecipesConfig.Exclude + } +} + +// mergeLocalMap copies every key from local into *base, allocating the base map +// if needed. Local values overwrite existing keys. +func mergeLocalMap[T any](base *map[string]T, local map[string]T) { + if len(local) == 0 { + return + } + if *base == nil { + *base = make(map[string]T, len(local)) + } + maps.Copy(*base, local) +} diff --git a/internal/config/local_test.go b/internal/config/local_test.go new file mode 100644 index 0000000..9601c09 --- /dev/null +++ b/internal/config/local_test.go @@ -0,0 +1,167 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLocalConfigPath(t *testing.T) { + tests := []struct { + name string + main string + want string + }{ + {"default config", "/home/u/.config/ralph/config.toml", "/home/u/.config/ralph/config.local.toml"}, + {"custom name", "/tmp/myralph.toml", "/tmp/myralph.local.toml"}, + {"no extension", "/tmp/ralphcfg", "/tmp/ralphcfg.local.toml"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := LocalConfigPath(tt.main); got != tt.want { + t.Errorf("LocalConfigPath(%q) = %q, want %q", tt.main, got, tt.want) + } + }) + } +} + +// writeConfigPair writes main + optional local config into a temp dir and +// returns the main config path. Pass local == "" to skip the overlay file. +func writeConfigPair(t *testing.T, main, local string) string { + t.Helper() + dir := t.TempDir() + mainPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(mainPath, []byte(main), 0o644); err != nil { + t.Fatalf("write main config: %v", err) + } + if local != "" { + localPath := filepath.Join(dir, "config.local.toml") + if err := os.WriteFile(localPath, []byte(local), 0o644); err != nil { + t.Fatalf("write local config: %v", err) + } + } + return mainPath +} + +func TestLoadLocalOverlay_NoFile(t *testing.T) { + mainPath := writeConfigPair(t, `dotfiles_repo_path = "~/dots"`, "") + + var cfg Config + cfg.DotfilesRepoPath = "~/dots" + + merged, err := loadLocalOverlay(&cfg, mainPath) + if err != nil { + t.Fatalf("loadLocalOverlay() error = %v", err) + } + if merged { + t.Error("loadLocalOverlay() reported a merge when no local file exists") + } + if cfg.DotfilesRepoPath != "~/dots" { + t.Errorf("DotfilesRepoPath changed unexpectedly: %q", cfg.DotfilesRepoPath) + } +} + +func TestLoadLocalOverlay_ScalarOverride(t *testing.T) { + main := ` +dotfiles_repo_path = "~/dots" +packages_dir = "~/.config/ralph/pkg" +` + local := ` +packages_dir = "/opt/ralph/pkg" +` + mainPath := writeConfigPair(t, main, local) + + cfg := Config{DotfilesRepoPath: "~/dots", PackagesDir: "~/.config/ralph/pkg"} + merged, err := loadLocalOverlay(&cfg, mainPath) + if err != nil { + t.Fatalf("loadLocalOverlay() error = %v", err) + } + if !merged { + t.Fatal("loadLocalOverlay() reported no merge") + } + if cfg.PackagesDir != "/opt/ralph/pkg" { + t.Errorf("PackagesDir = %q, want %q", cfg.PackagesDir, "/opt/ralph/pkg") + } + // Scalar not set in local must be preserved. + if cfg.DotfilesRepoPath != "~/dots" { + t.Errorf("DotfilesRepoPath = %q, want %q", cfg.DotfilesRepoPath, "~/dots") + } +} + +func TestLoadLocalOverlay_MapMergeLocalWins(t *testing.T) { + main := ` +[recipes_config.overrides.apple-dev] +enable = false + +[recipes_config.overrides.pi] +enable = true +` + local := ` +[recipes_config.overrides.apple-dev] +enable = true +` + mainPath := writeConfigPair(t, main, local) + + var cfg Config + cfg.RecipesConfig.Overrides = map[string]RecipeOverride{ + "apple-dev": {Enable: boolPtr(false)}, + "pi": {Enable: boolPtr(true)}, + } + + if _, err := loadLocalOverlay(&cfg, mainPath); err != nil { + t.Fatalf("loadLocalOverlay() error = %v", err) + } + + // Local wins for the overridden key. + if got := cfg.RecipesConfig.Overrides["apple-dev"].Enable; got == nil || !*got { + t.Errorf("apple-dev override = %v, want enable=true", got) + } + // Untouched base key is preserved. + if got := cfg.RecipesConfig.Overrides["pi"].Enable; got == nil || !*got { + t.Errorf("pi override = %v, want enable=true", got) + } +} + +func TestLoadLocalOverlay_MapMergeIntoNilBase(t *testing.T) { + main := `dotfiles_repo_path = "~/dots"` + local := ` +[shell.aliases.ll] +command = "ls -alh" +` + mainPath := writeConfigPair(t, main, local) + + var cfg Config // Shell.Aliases is nil + if _, err := loadLocalOverlay(&cfg, mainPath); err != nil { + t.Fatalf("loadLocalOverlay() error = %v", err) + } + if cfg.Shell.Aliases["ll"].Command != "ls -alh" { + t.Errorf("alias ll = %q, want %q", cfg.Shell.Aliases["ll"].Command, "ls -alh") + } +} + +func TestLoadLocalOverlay_SliceReplace(t *testing.T) { + main := ` +[[tools]] +name = "fzf" +check_command = "command -v fzf" +install_hint = "brew install fzf" +` + local := ` +[[tools]] +name = "ripgrep" +check_command = "command -v rg" +install_hint = "brew install ripgrep" +` + mainPath := writeConfigPair(t, main, local) + + cfg := Config{Tools: []Tool{{Name: "fzf"}}} + if _, err := loadLocalOverlay(&cfg, mainPath); err != nil { + t.Fatalf("loadLocalOverlay() error = %v", err) + } + + if len(cfg.Tools) != 1 || cfg.Tools[0].Name != "ripgrep" { + t.Errorf("Tools = %+v, want a single ripgrep entry (slice replace)", cfg.Tools) + } +} + +func boolPtr(b bool) *bool { return &b }