Skip to content

Commit 1b77a30

Browse files
v1truv1usJohn Ferguson
andauthored
feat: add profile-based config support (#11) (#20)
Implement profile-based configuration system that allows users to maintain multiple config variants (e.g., work, home, personal) and switch between them using the --profile flag. Features: - LoadWithProfile() function to load and merge profile configs - --profile global flag for all commands - Profile configs stored in ~/.rune/profiles/{name}.yaml - Safe merge behavior: profiles override base config where specified - Projects are appended, templates are merged, other fields override - Clear error messages for missing profiles - Full validation of merged configs Implementation: - Added config.LoadWithProfile() with profile merging logic - Added config.GetProfilePath() to locate profile files - Added config.mergeConfigs() with safe merge behavior - Added --profile flag to root command as persistent flag - Created loadConfigWithProfile() helper in commands/root.go - Updated all commands to use loadConfigWithProfile() - Comprehensive test coverage for all profile operations Tests: - TestLoadWithProfile_MissingProfile: validates error handling - TestLoadWithProfile_EmptyProfile: validates fallback to base config - TestLoadWithProfile_ValidProfile: validates merge behavior - TestMergeConfigs: validates merge logic in detail - TestGetProfilePath: validates profile path resolution All existing tests continue to pass. Co-authored-by: John Ferguson <john.ferguson@jferguson.info>
1 parent 31445c9 commit 1b77a30

File tree

10 files changed

+492
-21
lines changed

10 files changed

+492
-21
lines changed

internal/commands/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func runConfigValidate(cmd *cobra.Command, args []string) error {
127127
return nil
128128
}
129129

130-
cfg, err := config.Load()
130+
cfg, err := loadConfigWithProfile()
131131
if err != nil {
132132
fmt.Printf("❌ Configuration validation failed: %v\n", err)
133133
return nil // Don't return error to avoid double error message
@@ -181,7 +181,7 @@ func runConfigSetupTelemetry(cmd *cobra.Command, args []string) error {
181181
}
182182

183183
// Load existing config or create new one
184-
cfg, err := config.Load()
184+
cfg, err := loadConfigWithProfile()
185185
if err != nil {
186186
// If config doesn't exist, create a default one
187187
cfg = &config.Config{
@@ -391,7 +391,7 @@ func showTelemetryExamples() error {
391391
fmt.Println("📋 Current Status")
392392
fmt.Println("-----------------")
393393

394-
if cfg, err := config.Load(); err == nil {
394+
if cfg, err := loadConfigWithProfile(); err == nil {
395395
fmt.Printf("Telemetry: %s\n", map[bool]string{true: "✅ Enabled", false: "❌ Disabled"}[cfg.Integrations.Telemetry.Enabled])
396396
fmt.Printf("Sentry DSN: %s\n", maskTelemetryKey(cfg.Integrations.Telemetry.SentryDSN))
397397
} else {

internal/commands/debug.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"strings"
1010
"time"
1111

12-
"github.com/ferg-cod3s/rune/internal/config"
1312
"github.com/ferg-cod3s/rune/internal/telemetry"
1413
"github.com/getsentry/sentry-go"
1514
"github.com/spf13/cobra"
@@ -74,7 +73,7 @@ func runDebugTelemetry(cmd *cobra.Command, args []string) error {
7473

7574
// Configuration File
7675
fmt.Printf("\n📄 Configuration:\n")
77-
cfg, err := config.Load()
76+
cfg, err := loadConfigWithProfile()
7877
if err != nil {
7978
fmt.Printf(" Config Load Error: %v\n", err)
8079
} else {
@@ -131,7 +130,7 @@ func runDebugKeys(cmd *cobra.Command, args []string) error {
131130
fmt.Printf(" RUNE_SENTRY_DSN: %s\n", maskDSN(sentryEnv))
132131

133132
// Configuration File
134-
cfg, err := config.Load()
133+
cfg, err := loadConfigWithProfile()
135134
if err == nil {
136135
fmt.Printf("\n📄 Configuration File:\n")
137136
fmt.Printf(" Sentry DSN: %s\n", maskDSN(cfg.Integrations.Telemetry.SentryDSN))

internal/commands/logs.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"time"
1212

1313
"github.com/ferg-cod3s/rune/internal/colors"
14-
"github.com/ferg-cod3s/rune/internal/config"
1514
"github.com/spf13/cobra"
1615
"github.com/spf13/viper"
1716
)
@@ -142,7 +141,7 @@ func listLogFiles() error {
142141

143142
func getLogFilePath() string {
144143
// Try to get from config first
145-
if cfg, err := config.Load(); err == nil {
144+
if cfg, err := loadConfigWithProfile(); err == nil {
146145
if cfg.Logging.Output != "" && cfg.Logging.Output != "stdout" && cfg.Logging.Output != "stderr" {
147146
return cfg.Logging.Output
148147
}

internal/commands/ritual.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package commands
33
import (
44
"fmt"
55

6-
"github.com/ferg-cod3s/rune/internal/config"
76
"github.com/ferg-cod3s/rune/internal/rituals"
87
"github.com/ferg-cod3s/rune/internal/tracking"
98
"github.com/spf13/cobra"
@@ -57,7 +56,7 @@ func init() {
5756
}
5857

5958
func runRitualList(cmd *cobra.Command, args []string) error {
60-
cfg, err := config.Load()
59+
cfg, err := loadConfigWithProfile()
6160
if err != nil {
6261
return fmt.Errorf("failed to load config: %w", err)
6362
}
@@ -110,7 +109,7 @@ func runRitualList(cmd *cobra.Command, args []string) error {
110109
}
111110

112111
func runRitualTest(cmd *cobra.Command, args []string) error {
113-
cfg, err := config.Load()
112+
cfg, err := loadConfigWithProfile()
114113
if err != nil {
115114
return fmt.Errorf("failed to load config: %w", err)
116115
}
@@ -131,7 +130,7 @@ func runRitualTest(cmd *cobra.Command, args []string) error {
131130
}
132131

133132
func runRitualRun(cmd *cobra.Command, args []string) error {
134-
cfg, err := config.Load()
133+
cfg, err := loadConfigWithProfile()
135134
if err != nil {
136135
return fmt.Errorf("failed to load config: %w", err)
137136
}

internal/commands/root.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import (
1313
)
1414

1515
var (
16-
cfgFile string
17-
version = "dev"
16+
cfgFile string
17+
profileName string
18+
version = "dev"
1819
)
1920

2021
// rootCmd represents the base command when called without any subcommands
@@ -65,6 +66,7 @@ func init() {
6566

6667
// Global flags
6768
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.rune/config.yaml)")
69+
rootCmd.PersistentFlags().StringVar(&profileName, "profile", "", "config profile to use (e.g., work, home)")
6870
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
6971
rootCmd.PersistentFlags().Bool("no-color", false, "disable colored output")
7072
rootCmd.PersistentFlags().Bool("log", false, "print recent logs")
@@ -73,6 +75,7 @@ func init() {
7375
_ = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
7476
_ = viper.BindPFlag("no-color", rootCmd.PersistentFlags().Lookup("no-color"))
7577
_ = viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log"))
78+
_ = viper.BindPFlag("profile", rootCmd.PersistentFlags().Lookup("profile"))
7679
}
7780

7881
// initConfig reads in config file and ENV variables if set.
@@ -156,6 +159,15 @@ func maskTelemetryKeyForLogging(key string) string {
156159
return key[:4] + "****" + key[len(key)-4:]
157160
}
158161

162+
// loadConfigWithProfile loads config with profile support from the --profile flag
163+
func loadConfigWithProfile() (*config.Config, error) {
164+
profile := viper.GetString("profile")
165+
if profile != "" {
166+
return config.LoadWithProfile(profile)
167+
}
168+
return config.Load()
169+
}
170+
159171
// initColors initializes the color system
160172
func initColors() {
161173
// Check for --no-color flag

internal/commands/start.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package commands
33
import (
44
"fmt"
55

6-
"github.com/ferg-cod3s/rune/internal/config"
76
"github.com/ferg-cod3s/rune/internal/dnd"
87
"github.com/ferg-cod3s/rune/internal/notifications"
98
"github.com/ferg-cod3s/rune/internal/rituals"
@@ -44,7 +43,7 @@ func runStart(cmd *cobra.Command, args []string) error {
4443

4544
// Load configuration to get idle threshold
4645
var tracker *tracking.Tracker
47-
cfg, configErr := config.Load()
46+
cfg, configErr := loadConfigWithProfile()
4847
if configErr != nil {
4948
// Use default tracker if config fails to load
5049
var err error
@@ -92,7 +91,7 @@ func runStart(cmd *cobra.Command, args []string) error {
9291
// Load configuration and execute start rituals (reuse cfg if already loaded)
9392
if cfg == nil {
9493
var err error
95-
cfg, err = config.Load()
94+
cfg, err = loadConfigWithProfile()
9695
if err != nil {
9796
fmt.Printf("⚠ Could not load config for rituals: %v\n", err)
9897
}

internal/commands/status.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"fmt"
55

66
"github.com/ferg-cod3s/rune/internal/colors"
7-
"github.com/ferg-cod3s/rune/internal/config"
87
"github.com/ferg-cod3s/rune/internal/dnd"
98
"github.com/ferg-cod3s/rune/internal/notifications"
109
"github.com/ferg-cod3s/rune/internal/telemetry"
@@ -103,7 +102,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
103102
}
104103

105104
// Check DND status
106-
cfg, _ := config.Load()
105+
cfg, _ := loadConfigWithProfile()
107106
var notificationEnabled bool
108107
if cfg != nil {
109108
notificationEnabled = cfg.Settings.Notifications.Enabled

internal/commands/stop.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package commands
33
import (
44
"fmt"
55

6-
"github.com/ferg-cod3s/rune/internal/config"
76
"github.com/ferg-cod3s/rune/internal/dnd"
87
"github.com/ferg-cod3s/rune/internal/notifications"
98
"github.com/ferg-cod3s/rune/internal/rituals"
@@ -59,7 +58,7 @@ func runStop(cmd *cobra.Command, args []string) error {
5958
})
6059

6160
// Load configuration and execute stop rituals
62-
cfg, err := config.Load()
61+
cfg, err := loadConfigWithProfile()
6362
if err != nil {
6463
fmt.Printf("⚠ Could not load config for rituals: %v\n", err)
6564
} else {

internal/config/config.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,166 @@ func Load() (*Config, error) {
136136
return &cfg, nil
137137
}
138138

139+
// LoadWithProfile loads the base configuration and merges a profile if specified
140+
func LoadWithProfile(profileName string) (*Config, error) {
141+
// Load base config first
142+
baseCfg, err := Load()
143+
if err != nil {
144+
return nil, err
145+
}
146+
147+
// If no profile specified, return base config
148+
if profileName == "" {
149+
return baseCfg, nil
150+
}
151+
152+
// Get profile config path
153+
profilePath, err := GetProfilePath(profileName)
154+
if err != nil {
155+
return nil, err
156+
}
157+
158+
// Check if profile exists
159+
if _, err := os.Stat(profilePath); os.IsNotExist(err) {
160+
return nil, fmt.Errorf("profile '%s' not found at: %s", profileName, profilePath)
161+
}
162+
163+
// Create a new viper instance for profile
164+
profileViper := viper.New()
165+
profileViper.SetConfigFile(profilePath)
166+
profileViper.SetConfigType("yaml")
167+
168+
if err := profileViper.ReadInConfig(); err != nil {
169+
return nil, fmt.Errorf("failed to read profile '%s': %w", profileName, err)
170+
}
171+
172+
// Unmarshal profile config
173+
var profileCfg Config
174+
if err := profileViper.Unmarshal(&profileCfg); err != nil {
175+
return nil, fmt.Errorf("failed to unmarshal profile '%s': %w", profileName, err)
176+
}
177+
178+
// Merge profile into base config
179+
mergedCfg := mergeConfigs(baseCfg, &profileCfg)
180+
181+
// Validate merged config
182+
if err := mergedCfg.Validate(); err != nil {
183+
return nil, fmt.Errorf("merged config validation failed: %w", err)
184+
}
185+
186+
return mergedCfg, nil
187+
}
188+
189+
// GetProfilePath returns the path to a profile configuration file
190+
func GetProfilePath(profileName string) (string, error) {
191+
home, err := os.UserHomeDir()
192+
if err != nil {
193+
return "", fmt.Errorf("failed to get home directory: %w", err)
194+
}
195+
196+
return filepath.Join(home, ".rune", "profiles", profileName+".yaml"), nil
197+
}
198+
199+
// mergeConfigs merges profile config into base config
200+
// Profile values override base values for non-zero/non-empty fields
201+
func mergeConfigs(base, profile *Config) *Config {
202+
merged := *base
203+
204+
// Merge version if set in profile
205+
if profile.Version != 0 {
206+
merged.Version = profile.Version
207+
}
208+
209+
// Merge user ID if set in profile
210+
if profile.UserID != "" {
211+
merged.UserID = profile.UserID
212+
}
213+
214+
// Merge settings
215+
if profile.Settings.WorkHours != 0 {
216+
merged.Settings.WorkHours = profile.Settings.WorkHours
217+
}
218+
if profile.Settings.BreakInterval != 0 {
219+
merged.Settings.BreakInterval = profile.Settings.BreakInterval
220+
}
221+
if profile.Settings.IdleThreshold != 0 {
222+
merged.Settings.IdleThreshold = profile.Settings.IdleThreshold
223+
}
224+
225+
// Merge notifications (check if any notification setting is explicitly set)
226+
// For booleans, we can't distinguish between false and unset, so we merge all
227+
merged.Settings.Notifications = profile.Settings.Notifications
228+
229+
// Merge projects - profile projects append to base
230+
if len(profile.Projects) > 0 {
231+
merged.Projects = append(merged.Projects, profile.Projects...)
232+
}
233+
234+
// Merge rituals - profile rituals override base
235+
if len(profile.Rituals.Start.Global) > 0 {
236+
merged.Rituals.Start.Global = profile.Rituals.Start.Global
237+
}
238+
if len(profile.Rituals.Stop.Global) > 0 {
239+
merged.Rituals.Stop.Global = profile.Rituals.Stop.Global
240+
}
241+
242+
// Merge per-project rituals
243+
if profile.Rituals.Start.PerProject != nil {
244+
if merged.Rituals.Start.PerProject == nil {
245+
merged.Rituals.Start.PerProject = make(map[string][]Command)
246+
}
247+
for k, v := range profile.Rituals.Start.PerProject {
248+
merged.Rituals.Start.PerProject[k] = v
249+
}
250+
}
251+
if profile.Rituals.Stop.PerProject != nil {
252+
if merged.Rituals.Stop.PerProject == nil {
253+
merged.Rituals.Stop.PerProject = make(map[string][]Command)
254+
}
255+
for k, v := range profile.Rituals.Stop.PerProject {
256+
merged.Rituals.Stop.PerProject[k] = v
257+
}
258+
}
259+
260+
// Merge templates
261+
if profile.Rituals.Templates != nil {
262+
if merged.Rituals.Templates == nil {
263+
merged.Rituals.Templates = make(map[string]TmuxTemplate)
264+
}
265+
for k, v := range profile.Rituals.Templates {
266+
merged.Rituals.Templates[k] = v
267+
}
268+
}
269+
270+
// Merge integrations
271+
merged.Integrations.Git = profile.Integrations.Git
272+
if profile.Integrations.Slack.Workspace != "" {
273+
merged.Integrations.Slack = profile.Integrations.Slack
274+
}
275+
if profile.Integrations.Calendar.Provider != "" {
276+
merged.Integrations.Calendar = profile.Integrations.Calendar
277+
}
278+
if profile.Integrations.Telemetry.SentryDSN != "" {
279+
merged.Integrations.Telemetry = profile.Integrations.Telemetry
280+
}
281+
282+
// Merge logging
283+
if profile.Logging.Level != "" {
284+
merged.Logging.Level = profile.Logging.Level
285+
}
286+
if profile.Logging.Format != "" {
287+
merged.Logging.Format = profile.Logging.Format
288+
}
289+
if profile.Logging.Output != "" {
290+
merged.Logging.Output = profile.Logging.Output
291+
}
292+
if profile.Logging.ErrorFile != "" {
293+
merged.Logging.ErrorFile = profile.Logging.ErrorFile
294+
}
295+
296+
return &merged
297+
}
298+
139299
// Validate validates the configuration
140300
func (c *Config) Validate() error {
141301
if c.Version != 1 {

0 commit comments

Comments
 (0)