diff --git a/CLAUDE.md b/CLAUDE.md index a9cd943..35344db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ internal/ recipe.go Recipe loading, discovery, and merging sources.go Remote recipe sources ([[recipe_sources]]): cache under ~/.config/ralph/sources/ overrides.go Set/remove recipe overrides in config.toml (text-level, backup+validate) + vars.go Recipe variables: {{vars.}} expansion in shell items ([recipe.vars] defaults + override values) migrate.go MigrateFromLegacy (dotter → ralph) dotfile/ symlink.go Create/update symlinks and dir symlinks diff --git a/cmd/ralph/commands/cmd_apply.go b/cmd/ralph/commands/cmd_apply.go index 9f54104..69150ba 100644 --- a/cmd/ralph/commands/cmd_apply.go +++ b/cmd/ralph/commands/cmd_apply.go @@ -295,7 +295,9 @@ func applyDirsMirror(ctx *applyContext, symlinkAction dotfile.SymlinkAction) { fmt.Fprintf(ctx.w, " %s\n", bold(name)) // Resolve source directory - absoluteSource, err := config.ExpandPath(config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, dm.Source)) + absoluteSource, err := config.ExpandPath( + config.JoinSourcePath(ctx.cfg.DotfilesRepoPath, dm.Source), + ) if err != nil { fmt.Fprintln( os.Stderr, diff --git a/cmd/ralph/commands/sources_sync_test.go b/cmd/ralph/commands/sources_sync_test.go index 9f507a9..b238575 100644 --- a/cmd/ralph/commands/sources_sync_test.go +++ b/cmd/ralph/commands/sources_sync_test.go @@ -22,7 +22,8 @@ func makeSourceFixture(t *testing.T, name, ref string) (origin, checkout string) t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", ) @@ -62,7 +63,8 @@ func advanceOrigin(t *testing.T, origin string) string { for _, args := range [][]string{{"add", "."}, {"commit", "-m", "second"}} { cmd := exec.Command("git", args...) cmd.Dir = origin - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", ) diff --git a/docs/configuration.md b/docs/configuration.md index 9b507ef..1f2e5e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -544,12 +544,13 @@ exclude = ["experimental/*"] #### `[recipes_config.overrides.]` -Override `enable` and `hosts` for auto-discovered recipes by directory name. +Override `enable`, `hosts`, and `vars` for auto-discovered recipes by directory name. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `enable` | bool (pointer) | no | `nil` | Enable/disable. | | `hosts` | string array | no | `[]` | Host filtering. | +| `vars` | map | no | `{}` | Values for variables the recipe declares in `[recipe.vars]`. Overriding an undeclared variable is an error. See [Recipe variables](recipes.md#recipe-variables). | ```toml [recipes_config.overrides.work-tools] @@ -557,6 +558,9 @@ hosts = ["work-laptop"] [recipes_config.overrides.deprecated-stuff] enable = false + +[recipes_config.overrides."thismoon/toss-bin"] +vars = { alias_name = "del" } ``` ### `[[recipe_sources]]` @@ -612,11 +616,14 @@ profiles = ["personal"] # optional; recipe applies only on machines whose profi [recipe.legacy_paths] "ralph_files/nvim/init.lua" = "nvim/init.lua" +[recipe.vars] +editor_alias = "vim" # optional; referenced as {{vars.editor_alias}} in shell items, overridable per machine + [dotfiles.nvim_init] source = "init.lua" target = "~/.config/nvim/init.lua" -[shell.aliases.vim] +[shell.aliases."{{vars.editor_alias}}"] command = "nvim" ``` diff --git a/docs/recipes.md b/docs/recipes.md index 9f0b0e5..62cfbeb 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -29,6 +29,7 @@ wave = 0 # optional: build before wave-1 recipes | `wave` | int | Execution wave for the recipe's builds and packages (default `1`). Lower waves run first. See [Build ordering with waves](#build-ordering-with-waves). | | `legacy_paths` | map | Old-to-new path mappings for [migration](migration.md) after reorganizing files | | `delete_behavior` | string | `"delete"` (default) or `"abandon"`. Controls what happens to the recipe's artifacts when it disappears from the config. See [Recipe deletion and cleanup](#recipe-deletion-and-cleanup). | +| `vars` | map | Declared variables with default values, referenced as `{{vars.}}` in shell items and overridable per machine. See [Recipe variables](#recipe-variables). | ### Available sections @@ -111,7 +112,7 @@ enable = false | `auto_discover` | bool | Enable auto-discovery | | `dir` | string | Directory to search (default: `"recipes"`) | | `exclude` | list | Glob patterns to exclude from discovery | -| `overrides` | map | Per-recipe overrides keyed by directory name, supporting `enable` and `hosts` | +| `overrides` | map | Per-recipe overrides keyed by directory name, supporting `enable`, `hosts`, and `vars` | ### Remote recipe sources @@ -160,6 +161,30 @@ hosts = [] # explicit empty = all hosts is NOT supported; omit the field instea In practice, items without a `hosts` field inherit the recipe-level filter, and items with a `hosts` field use their own. +## Recipe variables + +A recipe can declare variables with defaults under `[recipe.vars]` and reference them as `{{vars.}}` in shell alias and function names, alias commands, and function bodies. A machine sets its own values per recipe through `[recipes_config.overrides.].vars` — the same key enable/hosts overrides use (directory name for local recipes, `"/"` for remote ones). + +```toml +# recipes/toss-bin/recipe.toml — ships an rm wrapper by default +[recipe] +name = "toss-bin" + +[recipe.vars] +alias_name = "rm" + +[shell.functions."{{vars.alias_name}}"] +body = 'toss-bin --safe-mode "$@"' +``` + +```toml +# config.toml on a machine that wants the wrapper under a different name +[recipes_config.overrides."thismoon/toss-bin"] +vars = { alias_name = "del" } +``` + +Expansion happens before the recipe merges into the config, so expanded names take part in [name conflict detection](#name-conflict-detection) and shell-name validation. Overriding a variable the recipe does not declare is an error, and so is referencing an undeclared variable — a typo fails the run instead of generating a half-expanded name. Variables expand only in shell aliases and functions; other recipe sections do not see them. + ## Name conflict detection Ralph raises an error if the same item name appears in multiple recipes or in both a recipe and the main config. This applies to dotfiles, directories, repos, shell aliases, shell functions, shell env vars, pre/post link hooks, builds, packages, and template variables. diff --git a/internal/config/recipe.go b/internal/config/recipe.go index 307f286..a81223e 100644 --- a/internal/config/recipe.go +++ b/internal/config/recipe.go @@ -407,6 +407,8 @@ func ProcessRecipes(cfg *Config, currentHost string) error { // Process each recipe for _, ref := range recipeRefs { + // Vars overrides key on the recipe directory name, like enable/hosts. + dirName := filepath.Base(filepath.Dir(ref.Path)) // Local recipes resolve paths relative to the dotfiles repo. if err := processRecipeRef( cfg, @@ -415,6 +417,7 @@ func ProcessRecipes(cfg *Config, currentHost string) error { filepath.Dir(ref.Path), "", currentHost, + cfg.RecipesConfig.Overrides[dirName].Vars, ); err != nil { return err } @@ -434,6 +437,7 @@ func processRecipeRef( ref RecipeRef, loadRoot, resolveDir, namePrefix string, currentHost string, + overrideVars map[string]string, ) error { // Disabled recipes are intentionally cleaned up — skip entirely. if !IsEnabled(ref.Enable) { @@ -484,6 +488,12 @@ func processRecipeRef( // Get recipe name for error messages recipeName := namePrefix + resolveRecipeName(recipe, ref) + // Expand {{vars.}} placeholders before merging so the final + // names take part in duplicate detection and name validation. + if err := ExpandRecipeVars(recipe, overrideVars); err != nil { + return fmt.Errorf("recipe '%s': %w", recipeName, err) + } + // Merge into config if err := MergeRecipeIntoConfig(cfg, recipe, recipeName); err != nil { return err @@ -556,6 +566,7 @@ func processRecipeSources(cfg *Config, currentHost string) error { filepath.Join(checkout, filepath.Dir(ref.Path)), src.Name+"/", currentHost, + cfg.RecipesConfig.Overrides[src.Name+"/"+ref.Name].Vars, ); err != nil { return err } diff --git a/internal/config/sources_test.go b/internal/config/sources_test.go index 0abec5e..6c3ab0d 100644 --- a/internal/config/sources_test.go +++ b/internal/config/sources_test.go @@ -25,7 +25,8 @@ func makeSourceRepo(t *testing.T, recipeName string) (repoPath, first, second st t.Helper() cmd := exec.Command("git", args...) cmd.Dir = repoPath - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", ) diff --git a/internal/config/types.go b/internal/config/types.go index 89ffcb7..8b378ba 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -183,10 +183,11 @@ type RecipeSource struct { // DefaultSourcesDir is the default cache directory for remote recipe sources. const DefaultSourcesDir = "~/.config/ralph/sources" -// RecipeOverride provides enable/hosts overrides for auto-discovered recipes. +// RecipeOverride provides enable/hosts/vars overrides for auto-discovered recipes. type RecipeOverride struct { - Enable *bool `toml:"enable,omitempty"` // nil/true = enabled, false = disabled - Hosts []string `toml:"hosts,omitempty"` // List of hostnames this recipe should apply to (empty = all hosts) + Enable *bool `toml:"enable,omitempty"` // nil/true = enabled, false = disabled + Hosts []string `toml:"hosts,omitempty"` // List of hostnames this recipe should apply to (empty = all hosts) + Vars map[string]string `toml:"vars,omitempty"` // Values for variables the recipe declares in [recipe.vars]; overriding an undeclared variable is an error } // RecipesConfig holds configuration for auto-discovery mode. @@ -245,6 +246,7 @@ type RecipeMetadata struct { Wave *int `toml:"wave,omitempty"` // Execution wave: lower waves complete first (nil = unset → defaults to 1; wave 0 runs before default) Caveats string `toml:"caveats,omitempty"` // Post-apply instructions shown when a package in this recipe is rebuilt Profiles []string `toml:"profiles,omitempty"` // Machine profile labels this recipe belongs to; recipe applies when these intersect the machine's profiles (empty = all) + Vars map[string]string `toml:"vars,omitempty"` // Declared variables with their defaults; referenced as {{vars.}} in shell alias/function names, commands, and bodies, overridable via [recipes_config.overrides.].vars } // DeleteBehaviorDelete instructs ralph to remove orphaned artifacts when a recipe is gone. diff --git a/internal/config/vars.go b/internal/config/vars.go new file mode 100644 index 0000000..ca496da --- /dev/null +++ b/internal/config/vars.go @@ -0,0 +1,113 @@ +package config + +import ( + "fmt" + "maps" + "regexp" + "sort" + "strings" +) + +// varPlaceholder matches {{vars.}} references in recipe strings. +var varPlaceholder = regexp.MustCompile(`\{\{vars\.([A-Za-z_][A-Za-z0-9_]*)\}\}`) + +// ExpandRecipeVars resolves {{vars.}} placeholders in the recipe's +// shell aliases and functions — names, commands, and bodies — using the +// recipe's declared [recipe.vars] defaults overlaid with per-machine +// override values. It runs before the recipe merges into the config, so +// expanded names take part in duplicate detection and name validation. +// Overriding a variable the recipe does not declare is an error, as is a +// placeholder no declared variable resolves. +func ExpandRecipeVars(recipe *Recipe, overrides map[string]string) error { + declared := recipe.Recipe.Vars + for name := range overrides { + if _, ok := declared[name]; !ok { + return fmt.Errorf( + "vars override '%s' is not declared in [recipe.vars] (declared: %s)", + name, + declaredNames(declared), + ) + } + } + + values := make(map[string]string, len(declared)) + maps.Copy(values, declared) + maps.Copy(values, overrides) + + expand := func(field, s string) (string, error) { + out := varPlaceholder.ReplaceAllStringFunc(s, func(match string) string { + name := varPlaceholder.FindStringSubmatch(match)[1] + if value, ok := values[name]; ok { + return value + } + return match + }) + if match := varPlaceholder.FindString(out); match != "" { + return "", fmt.Errorf( + "%s references %s, which is not declared in [recipe.vars] (declared: %s)", + field, + match, + declaredNames(declared), + ) + } + return out, nil + } + + aliases := make(map[string]ShellAlias, len(recipe.Shell.Aliases)) + for name, alias := range recipe.Shell.Aliases { + newName, err := expand(fmt.Sprintf("shell alias '%s'", name), name) + if err != nil { + return err + } + alias.Command, err = expand(fmt.Sprintf("shell alias '%s' command", name), alias.Command) + if err != nil { + return err + } + if _, exists := aliases[newName]; exists { + return fmt.Errorf( + "shell alias '%s': expanded name '%s' collides with another alias", + name, + newName, + ) + } + aliases[newName] = alias + } + recipe.Shell.Aliases = aliases + + functions := make(map[string]ShellFunction, len(recipe.Shell.Functions)) + for name, function := range recipe.Shell.Functions { + newName, err := expand(fmt.Sprintf("shell function '%s'", name), name) + if err != nil { + return err + } + function.Body, err = expand(fmt.Sprintf("shell function '%s' body", name), function.Body) + if err != nil { + return err + } + if _, exists := functions[newName]; exists { + return fmt.Errorf( + "shell function '%s': expanded name '%s' collides with another function", + name, + newName, + ) + } + functions[newName] = function + } + recipe.Shell.Functions = functions + + return nil +} + +// declaredNames renders the declared variable names sorted, for stable +// error messages; "none" when the recipe declares no variables. +func declaredNames(declared map[string]string) string { + if len(declared) == 0 { + return "none" + } + names := make([]string, 0, len(declared)) + for name := range declared { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go new file mode 100644 index 0000000..ef12962 --- /dev/null +++ b/internal/config/vars_test.go @@ -0,0 +1,226 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func recipeWithVars( + vars map[string]string, + functions map[string]ShellFunction, + aliases map[string]ShellAlias, +) *Recipe { + return &Recipe{ + Recipe: RecipeMetadata{Name: "test", Vars: vars}, + Shell: ShellConfig{Functions: functions, Aliases: aliases}, + } +} + +func TestExpandRecipeVars(t *testing.T) { + tests := []struct { + name string + recipe *Recipe + overrides map[string]string + wantErr string + check func(t *testing.T, r *Recipe) + }{ + { + name: "default value expands in function name and body", + recipe: recipeWithVars( + map[string]string{"alias_name": "rm"}, + map[string]ShellFunction{"{{vars.alias_name}}": {Body: "echo {{vars.alias_name}}"}}, + nil, + ), + check: func(t *testing.T, r *Recipe) { + fn, ok := r.Shell.Functions["rm"] + if !ok { + t.Fatalf("Functions keys = %v, want key 'rm'", keysOf(r.Shell.Functions)) + } + if fn.Body != "echo rm" { + t.Errorf("Body = %q, want %q", fn.Body, "echo rm") + } + }, + }, + { + name: "override replaces default", + recipe: recipeWithVars( + map[string]string{"alias_name": "rm"}, + map[string]ShellFunction{"{{vars.alias_name}}": {Body: "toss-bin \"$@\""}}, + nil, + ), + overrides: map[string]string{"alias_name": "del"}, + check: func(t *testing.T, r *Recipe) { + if _, ok := r.Shell.Functions["del"]; !ok { + t.Fatalf("Functions keys = %v, want key 'del'", keysOf(r.Shell.Functions)) + } + if _, ok := r.Shell.Functions["rm"]; ok { + t.Error("Functions still holds key 'rm', want it renamed to 'del'") + } + }, + }, + { + name: "alias name and command expand", + recipe: recipeWithVars( + map[string]string{"pager": "less"}, + nil, + map[string]ShellAlias{"{{vars.pager}}-follow": {Command: "{{vars.pager}} +F"}}, + ), + check: func(t *testing.T, r *Recipe) { + alias, ok := r.Shell.Aliases["less-follow"] + if !ok { + t.Fatalf("Aliases keys = %v, want key 'less-follow'", keysOf(r.Shell.Aliases)) + } + if alias.Command != "less +F" { + t.Errorf("Command = %q, want %q", alias.Command, "less +F") + } + }, + }, + { + name: "no vars declared leaves recipe untouched", + recipe: recipeWithVars( + nil, + map[string]ShellFunction{"repo": {Body: "csl repo \"$@\""}}, + nil, + ), + check: func(t *testing.T, r *Recipe) { + if _, ok := r.Shell.Functions["repo"]; !ok { + t.Errorf("Functions keys = %v, want key 'repo'", keysOf(r.Shell.Functions)) + } + }, + }, + { + name: "override of undeclared variable errors", + recipe: recipeWithVars(map[string]string{"alias_name": "rm"}, nil, nil), + overrides: map[string]string{"alias_nmae": "del"}, + wantErr: "vars override 'alias_nmae' is not declared", + }, + { + name: "override without any declared vars errors", + recipe: recipeWithVars(nil, nil, nil), + overrides: map[string]string{"alias_name": "del"}, + wantErr: "not declared in [recipe.vars] (declared: none)", + }, + { + name: "undeclared placeholder errors", + recipe: recipeWithVars( + map[string]string{"alias_name": "rm"}, + map[string]ShellFunction{"{{vars.alais_name}}": {Body: "x"}}, + nil, + ), + wantErr: "references {{vars.alais_name}}, which is not declared", + }, + { + name: "expansion collision between function names errors", + recipe: recipeWithVars( + map[string]string{"alias_name": "rm"}, + map[string]ShellFunction{ + "{{vars.alias_name}}": {Body: "a"}, + "rm": {Body: "b"}, + }, + nil, + ), + wantErr: "collides with another function", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExpandRecipeVars(tt.recipe, tt.overrides) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("ExpandRecipeVars() = nil, want error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want it to contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ExpandRecipeVars() returned error: %v", err) + } + tt.check(t, tt.recipe) + }) + } +} + +func keysOf[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func TestProcessRecipes_VarsOverrideReachesRecipe(t *testing.T) { + tempDir := t.TempDir() + + recipeDir := filepath.Join(tempDir, "recipes", "toss") + _ = os.MkdirAll(recipeDir, 0o755) + _ = os.WriteFile(filepath.Join(recipeDir, "recipe.toml"), []byte(` +[recipe] +name = "toss" + +[recipe.vars] +alias_name = "rm" + +[shell.functions."{{vars.alias_name}}"] +body = 'toss-bin --safe-mode "$@"' +`), 0o644) + + cfg := &Config{ + DotfilesRepoPath: tempDir, + RecipesConfig: RecipesConfig{ + AutoDiscover: true, + Overrides: map[string]RecipeOverride{ + "toss": {Vars: map[string]string{"alias_name": "del"}}, + }, + }, + } + + if err := ProcessRecipes(cfg, "test-host"); err != nil { + t.Fatalf("ProcessRecipes() returned error: %v", err) + } + + fn, ok := cfg.Shell.Functions["del"] + if !ok { + t.Fatalf("Shell.Functions keys = %v, want key 'del'", keysOf(cfg.Shell.Functions)) + } + if fn.Body != `toss-bin --safe-mode "$@"` { + t.Errorf("Body = %q, want the recipe body unchanged", fn.Body) + } + if _, ok := cfg.Shell.Functions["rm"]; ok { + t.Error("Shell.Functions holds 'rm', want only the overridden 'del'") + } +} + +func TestProcessRecipes_VarsDefaultWithoutOverride(t *testing.T) { + tempDir := t.TempDir() + + recipeDir := filepath.Join(tempDir, "recipes", "toss") + _ = os.MkdirAll(recipeDir, 0o755) + _ = os.WriteFile(filepath.Join(recipeDir, "recipe.toml"), []byte(` +[recipe] +name = "toss" + +[recipe.vars] +alias_name = "rm" + +[shell.functions."{{vars.alias_name}}"] +body = 'toss-bin --safe-mode "$@"' +`), 0o644) + + cfg := &Config{ + DotfilesRepoPath: tempDir, + RecipesConfig: RecipesConfig{AutoDiscover: true}, + } + + if err := ProcessRecipes(cfg, "test-host"); err != nil { + t.Fatalf("ProcessRecipes() returned error: %v", err) + } + + if _, ok := cfg.Shell.Functions["rm"]; !ok { + t.Fatalf("Shell.Functions keys = %v, want default key 'rm'", keysOf(cfg.Shell.Functions)) + } +} diff --git a/internal/gitutil/sync_test.go b/internal/gitutil/sync_test.go index c500e8f..77417c9 100644 --- a/internal/gitutil/sync_test.go +++ b/internal/gitutil/sync_test.go @@ -19,7 +19,8 @@ func makeFixtureRepo(t *testing.T) (repoPath, first, second string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = repoPath - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", ) @@ -112,7 +113,8 @@ func TestFetchAndPullAdvances(t *testing.T) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = src - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", )