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 }