Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions cmd/ralph/commands/cmd_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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)
}
}

Expand Down
23 changes: 16 additions & 7 deletions cmd/ralph/commands/cmd_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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,
})
}

Expand All @@ -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
Expand Down
71 changes: 71 additions & 0 deletions cmd/ralph/commands/doctor_host_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package commands

import (
"os"
"path/filepath"
"testing"

"github.com/mad01/ralph/internal/config"
Expand Down Expand Up @@ -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")
}
}
33 changes: 33 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions internal/config/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"os"
"slices"
"strings"
)

Expand Down Expand Up @@ -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
}
67 changes: 67 additions & 0 deletions internal/config/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
})
}
}
3 changes: 3 additions & 0 deletions internal/config/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions internal/config/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
11 changes: 11 additions & 0 deletions internal/config/recipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading