Skip to content

Commit 8613ffd

Browse files
AnnatarHeclaude
andcommitted
feat(config): support local config file override
Add support for reading local config files (e.g., config.local.toml) that override base config settings. This allows developers to maintain personal settings without modifying the version-controlled config file. - Extract file extension properly to support different config formats - Merge local config settings on top of base config - Add comprehensive tests for the new functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4926495 commit 8613ffd

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

model/config.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"path/filepath"
78
"strings"
89

910
"github.com/pelletier/go-toml/v2"
@@ -25,6 +26,41 @@ func NewConfigService(configFilePath string) ConfigService {
2526
}
2627
}
2728

29+
// mergeConfig merges local config settings into the base config
30+
// Local settings override base settings when they are non-zero values
31+
func mergeConfig(base, local *ShellTimeConfig) {
32+
if local.Token != "" {
33+
base.Token = local.Token
34+
}
35+
if local.APIEndpoint != "" {
36+
base.APIEndpoint = local.APIEndpoint
37+
}
38+
if local.WebEndpoint != "" {
39+
base.WebEndpoint = local.WebEndpoint
40+
}
41+
if local.FlushCount > 0 {
42+
base.FlushCount = local.FlushCount
43+
}
44+
if local.GCTime > 0 {
45+
base.GCTime = local.GCTime
46+
}
47+
if local.DataMasking != nil {
48+
base.DataMasking = local.DataMasking
49+
}
50+
if local.EnableMetrics != nil {
51+
base.EnableMetrics = local.EnableMetrics
52+
}
53+
if local.Encrypted != nil {
54+
base.Encrypted = local.Encrypted
55+
}
56+
if local.AI != nil {
57+
base.AI = local.AI
58+
}
59+
if len(local.Endpoints) > 0 {
60+
base.Endpoints = local.Endpoints
61+
}
62+
}
63+
2864
func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeConfig, err error) {
2965
ctx, span := modelTracer.Start(ctx, "config.read")
3066
defer span.End()
@@ -42,6 +78,25 @@ func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeCo
4278
return
4379
}
4480

81+
// Check for local config file and merge if exists
82+
// Extract the file extension and construct local config filename
83+
ext := filepath.Ext(configFile)
84+
if ext != "" {
85+
// Get the base name without extension
86+
baseName := strings.TrimSuffix(configFile, ext)
87+
// Construct local config filename: baseName + ".local" + ext
88+
localConfigFile := baseName + ".local" + ext
89+
90+
if localConfig, localErr := os.ReadFile(localConfigFile); localErr == nil {
91+
// Parse local config and merge with base config
92+
var localSettings ShellTimeConfig
93+
if unmarshalErr := toml.Unmarshal(localConfig, &localSettings); unmarshalErr == nil {
94+
// Merge local settings into base config
95+
mergeConfig(&config, &localSettings)
96+
}
97+
}
98+
}
99+
45100
// default 10 and at least 3 for performance reason
46101
if config.FlushCount == 0 {
47102
config.FlushCount = 10

model/config_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package model
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestReadConfigFileWithLocal(t *testing.T) {
14+
// Create a temporary directory for test configs
15+
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
16+
require.NoError(t, err)
17+
defer os.RemoveAll(tmpDir)
18+
19+
// Create base config file
20+
baseConfigPath := filepath.Join(tmpDir, "config.toml")
21+
baseConfig := `Token = 'base-token'
22+
APIEndpoint = 'https://api.base.com'
23+
WebEndpoint = 'https://base.com'
24+
FlushCount = 5
25+
GCTime = 7
26+
dataMasking = false
27+
enableMetrics = false
28+
encrypted = false`
29+
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
30+
require.NoError(t, err)
31+
32+
// Create local config file that overrides some settings
33+
localConfigPath := filepath.Join(tmpDir, "config.local.toml")
34+
localConfig := `Token = 'local-token'
35+
APIEndpoint = 'https://api.local.com'
36+
FlushCount = 10
37+
dataMasking = true`
38+
err = os.WriteFile(localConfigPath, []byte(localConfig), 0644)
39+
require.NoError(t, err)
40+
41+
// Test reading config with local override
42+
cs := NewConfigService(baseConfigPath)
43+
config, err := cs.ReadConfigFile(context.Background())
44+
require.NoError(t, err)
45+
46+
// Verify local config overrides base config
47+
assert.Equal(t, "local-token", config.Token, "Token should be overridden by local config")
48+
assert.Equal(t, "https://api.local.com", config.APIEndpoint, "APIEndpoint should be overridden by local config")
49+
assert.Equal(t, 10, config.FlushCount, "FlushCount should be overridden by local config")
50+
assert.True(t, *config.DataMasking, "DataMasking should be overridden by local config")
51+
52+
// Verify base config values that weren't overridden
53+
assert.Equal(t, "https://base.com", config.WebEndpoint, "WebEndpoint should keep base value")
54+
assert.Equal(t, 7, config.GCTime, "GCTime should keep base value")
55+
assert.False(t, *config.EnableMetrics, "EnableMetrics should keep base value")
56+
assert.False(t, *config.Encrypted, "Encrypted should keep base value")
57+
}
58+
59+
func TestReadConfigFileWithoutLocal(t *testing.T) {
60+
// Create a temporary directory for test configs
61+
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
62+
require.NoError(t, err)
63+
defer os.RemoveAll(tmpDir)
64+
65+
// Create only base config file (no local file)
66+
baseConfigPath := filepath.Join(tmpDir, "config.toml")
67+
baseConfig := `Token = 'base-token'
68+
APIEndpoint = 'https://api.base.com'
69+
WebEndpoint = 'https://base.com'
70+
FlushCount = 5
71+
GCTime = 7`
72+
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
73+
require.NoError(t, err)
74+
75+
// Test reading config without local file
76+
cs := NewConfigService(baseConfigPath)
77+
config, err := cs.ReadConfigFile(context.Background())
78+
require.NoError(t, err)
79+
80+
// Verify base config values are used
81+
assert.Equal(t, "base-token", config.Token)
82+
assert.Equal(t, "https://api.base.com", config.APIEndpoint)
83+
assert.Equal(t, "https://base.com", config.WebEndpoint)
84+
assert.Equal(t, 5, config.FlushCount)
85+
assert.Equal(t, 7, config.GCTime)
86+
}
87+
88+
func TestReadConfigFileWithDifferentExtensions(t *testing.T) {
89+
testCases := []struct {
90+
name string
91+
configFile string
92+
localFile string
93+
}{
94+
{
95+
name: "TOML files",
96+
configFile: "config.toml",
97+
localFile: "config.local.toml",
98+
},
99+
{
100+
name: "Custom config name",
101+
configFile: "shelltime-config.toml",
102+
localFile: "shelltime-config.local.toml",
103+
},
104+
{
105+
name: "Different extension",
106+
configFile: "settings.conf",
107+
localFile: "settings.local.conf",
108+
},
109+
}
110+
111+
for _, tc := range testCases {
112+
t.Run(tc.name, func(t *testing.T) {
113+
// Create a temporary directory for test configs
114+
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
115+
require.NoError(t, err)
116+
defer os.RemoveAll(tmpDir)
117+
118+
// Create base config file
119+
baseConfigPath := filepath.Join(tmpDir, tc.configFile)
120+
baseConfig := `Token = 'base-token'
121+
APIEndpoint = 'https://api.base.com'
122+
FlushCount = 5`
123+
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
124+
require.NoError(t, err)
125+
126+
// Create local config file
127+
localConfigPath := filepath.Join(tmpDir, tc.localFile)
128+
localConfig := `Token = 'local-token'
129+
FlushCount = 10`
130+
err = os.WriteFile(localConfigPath, []byte(localConfig), 0644)
131+
require.NoError(t, err)
132+
133+
// Test reading config with local override
134+
cs := NewConfigService(baseConfigPath)
135+
config, err := cs.ReadConfigFile(context.Background())
136+
require.NoError(t, err)
137+
138+
// Verify local config overrides base config
139+
assert.Equal(t, "local-token", config.Token, "Token should be overridden by local config for %s", tc.name)
140+
assert.Equal(t, 10, config.FlushCount, "FlushCount should be overridden by local config for %s", tc.name)
141+
assert.Equal(t, "https://api.base.com", config.APIEndpoint, "APIEndpoint should keep base value for %s", tc.name)
142+
})
143+
}
144+
}

0 commit comments

Comments
 (0)