From fb97f492f057764699e9930503be1645102b6140 Mon Sep 17 00:00:00 2001 From: Alexander Brandstedt Date: Tue, 23 Jun 2026 22:53:17 +0200 Subject: [PATCH] feat(config): recipe profiles for machine/recipe classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hostname pinning with semantic profile labels. A machine declares profiles in its config.local.toml (`profiles = ["personal"]`); a recipe declares the profiles it belongs to in recipe.toml (`[recipe] profiles = [...]`). A recipe applies when its profiles intersect the machine's; a recipe with no profiles applies everywhere. Coexists with `hosts` (AND). Profile-mismatched recipes are frozen into the existing HostFilteredRecipes bucket, same as host-filtered recipes. Vocabulary is freeform (no enum). - types.go: Profiles on Config (machine) and RecipeMetadata (recipe) - local.go: overlay sets machine profiles (slice-replace) - host.go: ShouldApplyForProfiles (intersection; empty = applies everywhere) - recipe.go: profile filter in ProcessRecipes after load - ralph doctor: single config.local.toml line — loaded+profiles / loaded no-profiles (warn) / missing (warn) - ralph list recipes: show each recipe's profiles - docs/configuration.md: profiles model + doctor warning Closes MAD-85 --- cmd/ralph/commands/cmd_doctor.go | 30 +++++++---- cmd/ralph/commands/cmd_list.go | 23 ++++++--- cmd/ralph/commands/doctor_host_test.go | 71 ++++++++++++++++++++++++++ docs/configuration.md | 33 ++++++++++++ internal/config/host.go | 18 +++++++ internal/config/host_test.go | 67 ++++++++++++++++++++++++ internal/config/local.go | 3 ++ internal/config/local_test.go | 21 ++++++++ internal/config/recipe.go | 11 ++++ internal/config/recipe_test.go | 59 +++++++++++++++++++++ internal/config/types.go | 17 ++++-- 11 files changed, 331 insertions(+), 22 deletions(-) diff --git a/cmd/ralph/commands/cmd_doctor.go b/cmd/ralph/commands/cmd_doctor.go index e1b1164..d188e6f 100644 --- a/cmd/ralph/commands/cmd_doctor.go +++ b/cmd/ralph/commands/cmd_doctor.go @@ -45,7 +45,7 @@ var doctorCmd = &cobra.Command{ return &ExitError{Code: 1} } - checkConfig(rpt) + checkConfig(rpt, cfg) checkDotfiles(rpt, cfg) checkDirectories(rpt, cfg) checkRepositories(rpt, cfg) @@ -62,9 +62,11 @@ 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) { +// checkConfig reports on config file resolution: the active config path and the +// config.local.toml overlay (loaded with its profiles, loaded without profiles, +// or missing). A machine with no profiles is a warning because profile-scoped +// recipes won't apply to it. +func checkConfig(rpt *report.Report, cfg *config.Config) { phase := rpt.AddPhase("Configuration") configPath, err := config.GetDefaultConfigPath() @@ -74,13 +76,21 @@ func checkConfig(rpt *report.Report) { } phase.AddResult("config.toml", "", report.StatusOK, configPath, nil) + // A missing overlay and an overlay with no profiles are the same problem + // from the operator's view — the machine has no profiles — so report it as a + // single warning rather than two. Only when the overlay exists do we + // distinguish "loaded" from "loaded but no profiles". 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) + _, statErr := os.Stat(localPath) + switch { + case statErr == nil && len(cfg.Profiles) > 0: + phase.AddResult("config.local.toml", "", report.StatusOK, "loaded: "+strings.Join(cfg.Profiles, ", "), nil) + case statErr == nil: + phase.AddResult("config.local.toml", "", report.StatusWarn, "loaded but no profiles set", nil) + case os.IsNotExist(statErr): + phase.AddResult("config.local.toml", "", report.StatusWarn, "not found — machine has no profiles; create "+localPath, nil) + default: + phase.AddResult("config.local.toml", "", report.StatusWarn, fmt.Sprintf("%v", statErr), statErr) } } diff --git a/cmd/ralph/commands/cmd_list.go b/cmd/ralph/commands/cmd_list.go index 63f9650..300fe75 100644 --- a/cmd/ralph/commands/cmd_list.go +++ b/cmd/ralph/commands/cmd_list.go @@ -242,9 +242,10 @@ var listRecipesCmd = &cobra.Command{ // Collect recipe info for display type recipeInfo struct { - name string - enabled bool - summary string + name string + enabled bool + summary string + profiles []string } var recipes []recipeInfo @@ -259,10 +260,13 @@ var listRecipesCmd = &cobra.Command{ name := ref.Name summary := "" + var profiles []string if loadErr != nil { summary = fmt.Sprintf("(error: %v)", loadErr) } else { + profiles = recipe.Recipe.Profiles + if recipe.Recipe.Name != "" { name = ref.Name // Use directory name for consistency in listing } @@ -280,9 +284,10 @@ var listRecipesCmd = &cobra.Command{ } recipes = append(recipes, recipeInfo{ - name: name, - enabled: enabled, - summary: summary, + name: name, + enabled: enabled, + summary: summary, + profiles: profiles, }) } @@ -303,7 +308,11 @@ var listRecipesCmd = &cobra.Command{ } padding := strings.Repeat(" ", maxNameLen-len(r.name)+2) - fmt.Printf(" %s%s%-10s %s\n", r.name, padding, status, r.summary) + profileNote := "" + if len(r.profiles) > 0 { + profileNote = " [" + strings.Join(r.profiles, ",") + "]" + } + fmt.Printf(" %s%s%-10s %s%s\n", r.name, padding, status, r.summary, profileNote) } // Footer diff --git a/cmd/ralph/commands/doctor_host_test.go b/cmd/ralph/commands/doctor_host_test.go index 0135a26..d4f3eeb 100644 --- a/cmd/ralph/commands/doctor_host_test.go +++ b/cmd/ralph/commands/doctor_host_test.go @@ -1,6 +1,8 @@ package commands import ( + "os" + "path/filepath" "testing" "github.com/mad01/ralph/internal/config" @@ -74,3 +76,72 @@ func TestCheckDotfiles_AllHostsNotSkipped(t *testing.T) { t.Fatalf("ungated dotfile was skipped: %q", step.Message) } } + +// withDoctorConfig writes a config.toml (and optional config.local.toml) into a +// temp dir and points GetDefaultConfigPath at it for the duration of the test. +func withDoctorConfig(t *testing.T, localBody string) { + t.Helper() + dir := t.TempDir() + mainPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(mainPath, []byte(`dotfiles_repo_path = "~/dots"`), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + if localBody != "" { + if err := os.WriteFile(filepath.Join(dir, "config.local.toml"), []byte(localBody), 0o644); err != nil { + t.Fatalf("write local config: %v", err) + } + } + orig := config.GetDefaultConfigPath + config.GetDefaultConfigPath = func() (string, error) { return mainPath, nil } + t.Cleanup(func() { config.GetDefaultConfigPath = orig }) +} + +// A missing config.local.toml overlay must surface as a warning (the machine +// has no profiles), not a silent skip. +func TestCheckConfig_MissingOverlayWarns(t *testing.T) { + withDoctorConfig(t, "") + + rpt := &report.Report{Command: "doctor"} + checkConfig(rpt, &config.Config{}) + + overlay := findStep(rpt, "Configuration", "config.local.toml") + if overlay == nil { + t.Fatal("expected a config.local.toml result") + } + if overlay.Status != report.StatusWarn { + t.Fatalf("missing overlay status = %v, want warn (msg=%q)", overlay.Status, overlay.Message) + } +} + +// An overlay that exists but sets no profiles is still a warning. +func TestCheckConfig_OverlayWithoutProfilesWarns(t *testing.T) { + withDoctorConfig(t, `packages_dir = "/tmp/pkg"`) + + rpt := &report.Report{Command: "doctor"} + checkConfig(rpt, &config.Config{}) // overlay present on disk, but no profiles parsed + + overlay := findStep(rpt, "Configuration", "config.local.toml") + if overlay == nil || overlay.Status != report.StatusWarn { + t.Fatalf("overlay-without-profiles = %v, want warn", overlay) + } +} + +// With the overlay present and machine profiles set, the result is OK and lists +// them. +func TestCheckConfig_ReportsProfiles(t *testing.T) { + withDoctorConfig(t, `profiles = ["personal", "homelab"]`) + + rpt := &report.Report{Command: "doctor"} + checkConfig(rpt, &config.Config{Profiles: []string{"personal", "homelab"}}) + + overlay := findStep(rpt, "Configuration", "config.local.toml") + if overlay == nil { + t.Fatal("expected a config.local.toml result") + } + if overlay.Status != report.StatusOK { + t.Fatalf("overlay status = %v, want OK", overlay.Status) + } + if overlay.Message != "loaded: personal, homelab" { + t.Fatalf("overlay message = %q, want %q", overlay.Message, "loaded: personal, homelab") + } +} diff --git a/docs/configuration.md b/docs/configuration.md index e6e6e85..96efd03 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,6 +58,36 @@ enable = false This enables `apple-dev` and disables `pi` on this machine only, without editing the committed `config.toml`. +## Machine Profiles + +A machine declares its profiles with a top-level `profiles` list. A recipe declares the profiles it belongs to with `[recipe] profiles`. A recipe applies on a machine when the two lists share at least one label. This replaces pinning a recipe to specific hostnames when you want to group machines by role rather than by name. + +Profile labels are freeform strings, so any value works. The common labels are `personal`, `work`, and `homelab`, but you choose your own vocabulary. + +Set `profiles` only in the overlay, so each machine declares its own role without editing the shared `config.toml`: + +```toml +# config.local.toml +profiles = ["personal"] +``` + +A machine with no overlay has no profiles. In that state, a recipe scoped to any profile does not apply, and `ralph doctor` warns that the overlay is missing. + +### Match Rule + +| Recipe `profiles` | Machine `profiles` | Recipe applies? | +|-------------------|--------------------|-----------------| +| empty / unset | anything | yes (applies everywhere) | +| `["work"]` | `["work", "homelab"]` | yes (lists intersect) | +| `["work"]` | `["personal"]` | no (no shared label) | +| `["work"]` | empty | no | + +A recipe with no `profiles` applies on every machine, which keeps existing recipes working unchanged. + +### Profiles Alongside `hosts` + +Profiles and `hosts` are independent filters. When a recipe sets both, both must pass: the machine's profiles must intersect the recipe's profiles **and** the current hostname must match the recipe's `hosts`. Use profiles to group by role and `hosts` to pin to a single machine. + ## Top-Level Fields | Field | Type | Required | Default | Description | @@ -539,6 +569,7 @@ name = "Neovim" description = "Neovim editor configuration" wave = 1 # default; lower waves build first (see recipes.md) delete_behavior = "delete" # default; "abandon" leaves orphans in place +profiles = ["personal"] # optional; recipe applies only on machines whose profiles intersect this (empty = all machines) [recipe.legacy_paths] "ralph_files/nvim/init.lua" = "nvim/init.lua" @@ -553,6 +584,8 @@ command = "nvim" The `delete_behavior` field controls cleanup when the recipe is removed from your config or disabled. See [recipes](recipes.md#recipe-deletion-and-cleanup) for the full deletion model. +The `profiles` field scopes the recipe to machines by role. See [Machine Profiles](#machine-profiles) for the match rule and how it combines with `hosts`. A profile-filtered recipe is frozen on non-matching machines (its previously recorded artifacts are left in place, not cleaned up) exactly like a host-filtered recipe. + ## State files Ralph keeps two JSON state files under `~/.config/ralph/`. State files are written atomically (write to temp file, then rename), so a crash during apply never leaves corrupted state. diff --git a/internal/config/host.go b/internal/config/host.go index 7bb720b..afcfe42 100644 --- a/internal/config/host.go +++ b/internal/config/host.go @@ -2,6 +2,7 @@ package config import ( "os" + "slices" "strings" ) @@ -40,3 +41,20 @@ func ShouldApplyForHost(hosts []string, currentHost string) bool { } return false } + +// ShouldApplyForProfiles checks if a recipe should apply based on its profile +// labels and the machine's profiles. Empty/nil recipeProfiles means apply +// everywhere. Otherwise it applies when the two sets intersect: at least one +// recipe profile is present in machineProfiles. Comparison is exact (profiles +// are freeform strings, not normalized like hostnames). +func ShouldApplyForProfiles(recipeProfiles, machineProfiles []string) bool { + if len(recipeProfiles) == 0 { + return true + } + for _, rp := range recipeProfiles { + if slices.Contains(machineProfiles, rp) { + return true + } + } + return false +} diff --git a/internal/config/host_test.go b/internal/config/host_test.go index 3eaf412..a3dac3d 100644 --- a/internal/config/host_test.go +++ b/internal/config/host_test.go @@ -197,3 +197,70 @@ func TestShouldApplyForHost_MultipleHosts(t *testing.T) { }) } } + +func TestShouldApplyForProfiles(t *testing.T) { + tests := []struct { + name string + recipeProfiles []string + machineProfiles []string + expected bool + }{ + { + name: "empty recipe profiles applies everywhere", + recipeProfiles: nil, + machineProfiles: []string{"personal"}, + expected: true, + }, + { + name: "empty recipe profiles applies with no machine profiles", + recipeProfiles: []string{}, + machineProfiles: nil, + expected: true, + }, + { + name: "single intersection", + recipeProfiles: []string{"personal"}, + machineProfiles: []string{"personal"}, + expected: true, + }, + { + name: "disjoint sets", + recipeProfiles: []string{"work"}, + machineProfiles: []string{"personal"}, + expected: false, + }, + { + name: "non-empty recipe profiles, empty machine profiles", + recipeProfiles: []string{"personal"}, + machineProfiles: nil, + expected: false, + }, + { + name: "multi-profile overlap", + recipeProfiles: []string{"work", "homelab"}, + machineProfiles: []string{"personal", "homelab"}, + expected: true, + }, + { + name: "multi-profile no overlap", + recipeProfiles: []string{"work", "homelab"}, + machineProfiles: []string{"personal", "home"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldApplyForProfiles(tt.recipeProfiles, tt.machineProfiles) + if result != tt.expected { + t.Errorf( + "ShouldApplyForProfiles(%v, %v) = %v, want %v", + tt.recipeProfiles, + tt.machineProfiles, + result, + tt.expected, + ) + } + }) + } +} diff --git a/internal/config/local.go b/internal/config/local.go index 328e71e..92cbdd4 100644 --- a/internal/config/local.go +++ b/internal/config/local.go @@ -119,6 +119,9 @@ func mergeLocalConfig(base, local *Config) { if len(local.RecipesConfig.Exclude) > 0 { base.RecipesConfig.Exclude = local.RecipesConfig.Exclude } + if len(local.Profiles) > 0 { + base.Profiles = local.Profiles + } } // mergeLocalMap copies every key from local into *base, allocating the base map diff --git a/internal/config/local_test.go b/internal/config/local_test.go index 9601c09..7c44e30 100644 --- a/internal/config/local_test.go +++ b/internal/config/local_test.go @@ -164,4 +164,25 @@ install_hint = "brew install ripgrep" } } +func TestLoadLocalOverlay_SetsProfiles(t *testing.T) { + main := `dotfiles_repo_path = "~/dots"` + local := `profiles = ["personal", "homelab"]` + mainPath := writeConfigPair(t, main, local) + + cfg := Config{DotfilesRepoPath: "~/dots"} + if _, err := loadLocalOverlay(&cfg, mainPath); err != nil { + t.Fatalf("loadLocalOverlay() error = %v", err) + } + + want := []string{"personal", "homelab"} + if len(cfg.Profiles) != len(want) { + t.Fatalf("Profiles = %v, want %v", cfg.Profiles, want) + } + for i, p := range want { + if cfg.Profiles[i] != p { + t.Errorf("Profiles[%d] = %q, want %q", i, cfg.Profiles[i], p) + } + } +} + func boolPtr(b bool) *bool { return &b } diff --git a/internal/config/recipe.go b/internal/config/recipe.go index e3745b9..90d957e 100644 --- a/internal/config/recipe.go +++ b/internal/config/recipe.go @@ -434,6 +434,17 @@ func ProcessRecipes(cfg *Config, currentHost string) error { return fmt.Errorf("failed to load recipe '%s': %w", ref.Path, err) } + // Profile-filtered recipes belong to other machine profiles: freeze rather + // than apply, same as host-filtered recipes. Machine profiles come from the + // config.local.toml overlay (cfg.Profiles). Unlike the host filter, this + // must run after the load-error check above: a recipe's profiles live in + // its recipe.toml, so the recipe has to parse before we can read them — an + // unparseable on-host recipe is a hard error regardless of profile. + if !ShouldApplyForProfiles(recipe.Recipe.Profiles, cfg.Profiles) { + cfg.HostFilteredRecipes = append(cfg.HostFilteredRecipes, resolveRecipeName(recipe, ref)) + continue + } + // Get the directory containing the recipe recipeDir := filepath.Dir(ref.Path) diff --git a/internal/config/recipe_test.go b/internal/config/recipe_test.go index 485fec1..f8eb38a 100644 --- a/internal/config/recipe_test.go +++ b/internal/config/recipe_test.go @@ -441,6 +441,65 @@ target = "~/.myfile" } } +func TestProcessRecipes_ProfileFilter(t *testing.T) { + tempDir := t.TempDir() + + writeRecipe := func(name string, profiles string) { + dir := filepath.Join(tempDir, name) + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "recipe.toml"), []byte(` +[recipe] +name = "`+name+`" +`+profiles+` + +[dotfiles.`+name+`file] +source = "file.txt" +target = "~/.`+name+`file" +`), 0o644) + } + + // matching: machine profile intersects; everywhere: no profiles; other: disjoint. + writeRecipe("matching", `profiles = ["personal"]`) + writeRecipe("everywhere", ``) + writeRecipe("other", `profiles = ["work"]`) + + cfg := &Config{ + DotfilesRepoPath: tempDir, + Profiles: []string{"personal"}, + Recipes: []RecipeRef{ + {Path: "matching/recipe.toml"}, + {Path: "everywhere/recipe.toml"}, + {Path: "other/recipe.toml"}, + }, + } + + if err := ProcessRecipes(cfg, "test-host"); err != nil { + t.Fatalf("ProcessRecipes() returned error: %v", err) + } + + // matching + everywhere apply; other is profile-frozen. + if _, ok := cfg.Dotfiles["matchingfile"]; !ok { + t.Error("matching recipe should apply (profile intersects)") + } + if _, ok := cfg.Dotfiles["everywherefile"]; !ok { + t.Error("recipe with no profiles should apply everywhere") + } + if _, ok := cfg.Dotfiles["otherfile"]; ok { + t.Error("recipe with disjoint profiles should not apply") + } + + // The frozen recipe lands in HostFilteredRecipes (shared freeze bucket). + found := false + for _, n := range cfg.HostFilteredRecipes { + if n == "other" { + found = true + } + } + if !found { + t.Errorf("HostFilteredRecipes = %v, want it to contain \"other\"", cfg.HostFilteredRecipes) + } +} + func TestProcessRecipes_ShortName(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/config/types.go b/internal/config/types.go index 4401d35..e07b422 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -17,15 +17,21 @@ type Config struct { Recipes []RecipeRef `toml:"recipes"` // Explicit recipe references (Mode A) RecipesConfig RecipesConfig `toml:"recipes_config"` // Auto-discovery configuration (Mode B) + // Profiles are the machine's semantic profile labels (e.g. "personal", + // "work"). A recipe applies when its profiles intersect these. Normally set + // only via the machine-local config.local.toml overlay. + Profiles []string `toml:"profiles,omitempty"` + // loadedRecipes stores metadata about loaded recipes for migration support. // This is populated during config loading and not from the TOML file. LoadedRecipes []LoadedRecipeInfo `toml:"-"` - // HostFilteredRecipes lists recipes that are enabled but skipped on this - // host because of a host filter. Unlike disabled recipes (which should be - // cleaned up), these belong to other hosts and their previously-recorded - // artifacts must be frozen, not treated as orphans. Populated during - // recipe loading; not read from the TOML file. + // HostFilteredRecipes lists recipes that are enabled but not active on this + // machine — filtered out by a host gate or by a profile mismatch. Unlike + // disabled recipes (which should be cleaned up), these belong to other + // hosts/profiles and their previously-recorded artifacts must be frozen, not + // treated as orphans. Populated during recipe loading; not read from the + // TOML file. HostFilteredRecipes []string `toml:"-"` } @@ -222,6 +228,7 @@ type RecipeMetadata struct { DeleteBehavior string `toml:"delete_behavior,omitempty"` // "delete" (default) or "abandon" — how cleanup handles orphans when this recipe is removed 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) } // DeleteBehaviorDelete instructs ralph to remove orphaned artifacts when a recipe is gone.