Skip to content

Commit 8056444

Browse files
authored
feat: migrate experiments config from array to bools (#398)
* feat: migrate experiments config from array to map Change the experiments field in SystemConfig, ProjectConfig, and Config from []experiment.Experiment to map[string]bool. This enables dot-notation access for the upcoming config command and allows explicit enable/disable of experiments. Includes backwards-compatible JSON unmarshaling that auto-converts the old array format (["charm"]) to the new map format ({"charm": true}). Updates help output to show all available experiments with ENABLED/DISABLED status for better discoverability. * revert: remove help menu experiment display changes Reverts the EXPERIMENTS section changes from the experiments migration commit to keep PR #1 focused on the array-to-map refactor. Help menu improvements will be done in a separate PR. * test: tighten up the tests * test: tighten up the tests * refactor: use experiment.Experiment type as experiments map key Use the stricter experiment.Experiment type instead of string for the internal Config.experiments map key to improve type safety. * fix: reorder experiment loading so flags have highest priority Reorder experiment source precedence so that higher-priority sources override lower ones: permanently enabled < system config < project config < flags. * fix: remove backwards compatibility for old experiment array format Remove custom UnmarshalJSON methods and the unmarshalExperimentsField helper that supported the deprecated array format for experiments in config.json. The old format (["exp1"]) now produces a parse error with a remediation hint to update to the object format ({"exp1": true}).
1 parent 857a59a commit 8056444

File tree

5 files changed

+131
-63
lines changed

5 files changed

+131
-63
lines changed

internal/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ type Config struct {
6363

6464
// Feature experiments
6565
ExperimentsFlag []string
66-
experiments []experiment.Experiment
66+
experiments map[experiment.Experiment]bool
6767

6868
// Eventually this will also load the global and project slack config files
6969
DomainAuthTokens string

internal/config/experiments.go

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,59 +17,100 @@ package config
1717
import (
1818
"context"
1919
"fmt"
20-
"slices"
20+
"maps"
21+
"sort"
22+
"strings"
2123

2224
"github.com/slackapi/slack-cli/internal/experiment"
2325
"github.com/slackapi/slack-cli/internal/slackerror"
2426
)
2527

28+
// experimentsFormatHint is a remediation hint for config files that use the
29+
// deprecated array format for experiments instead of the current object format.
30+
const experimentsFormatHint = `If the "experiments" field is an array (e.g. ["exp1"]), update it to an object (e.g. {"exp1": true})`
31+
2632
// LoadExperiments parses experiments from the command flags and configuration
2733
// files and stores the findings in Config
2834
func (c *Config) LoadExperiments(
2935
ctx context.Context,
3036
printDebug func(ctx context.Context, format string, a ...interface{}),
3137
) {
32-
experiments := []experiment.Experiment{}
33-
// Load from flags
34-
for _, flagValue := range c.ExperimentsFlag {
35-
experiments = append(experiments, experiment.Experiment(flagValue))
38+
experiments := map[experiment.Experiment]bool{}
39+
// Load from permanently enabled list (lowest priority)
40+
for _, exp := range experiment.EnabledExperiments {
41+
experiments[exp] = true
42+
}
43+
printDebug(ctx, fmt.Sprintf("active permanently enabled experiments: %s", experiment.EnabledExperiments))
44+
// Load from system config file
45+
userConfig, err := c.SystemConfig.UserConfig(ctx)
46+
if err != nil {
47+
printDebug(ctx, fmt.Sprintf("failed to parse system-level config file: %s", err))
48+
} else {
49+
printDebug(ctx, fmt.Sprintf("active system experiments: %s", formatExperimentMap(toExperimentMap(userConfig.Experiments))))
50+
maps.Copy(experiments, toExperimentMap(userConfig.Experiments))
3651
}
37-
printDebug(ctx, fmt.Sprintf("active flag experiments: %s", experiments))
3852
// Load from project config file
3953
projectConfig, err := ReadProjectConfigFile(ctx, c.fs, c.os)
4054
if err != nil && slackerror.ToSlackError(err).Code != slackerror.ErrInvalidAppDirectory {
4155
printDebug(ctx, fmt.Sprintf("failed to parse project-level config file: %s", err))
4256
} else if err == nil {
43-
printDebug(ctx, fmt.Sprintf("active project experiments: %s", projectConfig.Experiments))
44-
experiments = append(experiments, projectConfig.Experiments...)
57+
printDebug(ctx, fmt.Sprintf("active project experiments: %s", formatExperimentMap(toExperimentMap(projectConfig.Experiments))))
58+
maps.Copy(experiments, toExperimentMap(projectConfig.Experiments))
4559
}
46-
// Load from system config file
47-
userConfig, err := c.SystemConfig.UserConfig(ctx)
48-
if err != nil {
49-
printDebug(ctx, fmt.Sprintf("failed to parse system-level config file: %s", err))
50-
} else {
51-
printDebug(ctx, fmt.Sprintf("active system experiments: %s", userConfig.Experiments))
52-
experiments = append(experiments, userConfig.Experiments...)
60+
// Load from flags (highest priority)
61+
for _, flagValue := range c.ExperimentsFlag {
62+
experiments[experiment.Experiment(flagValue)] = true
5363
}
54-
// Load from permanently enabled list
55-
experiments = append(experiments, experiment.EnabledExperiments...)
56-
printDebug(ctx, fmt.Sprintf("active permanently enabled experiments: %s", experiment.EnabledExperiments))
57-
// Sort, trim, and audit the experiments list
58-
slices.Sort(experiments)
59-
c.experiments = slices.Compact(experiments)
60-
for _, exp := range c.experiments {
61-
if !experiment.Includes(exp) {
62-
printDebug(ctx, fmt.Sprintf("invalid experiment found: %s", exp))
64+
printDebug(ctx, fmt.Sprintf("active flag experiments: %s", formatExperimentMap(experiments)))
65+
// Audit the experiments
66+
c.experiments = experiments
67+
for name := range c.experiments {
68+
if !experiment.Includes(name) {
69+
printDebug(ctx, fmt.Sprintf("invalid experiment found: %s", name))
6370
}
6471
}
6572
}
6673

6774
// GetExperiments returns the set of active experiments
6875
func (c *Config) GetExperiments() []experiment.Experiment {
69-
return c.experiments
76+
result := make([]experiment.Experiment, 0, len(c.experiments))
77+
for name, enabled := range c.experiments {
78+
if enabled {
79+
result = append(result, name)
80+
}
81+
}
82+
sort.Slice(result, func(i, j int) bool {
83+
return result[i] < result[j]
84+
})
85+
return result
7086
}
7187

7288
// WithExperimentOn checks whether an experiment is currently toggled on
7389
func (c *Config) WithExperimentOn(experimentToCheck experiment.Experiment) bool {
74-
return slices.Contains(c.experiments, experimentToCheck)
90+
return c.experiments[experimentToCheck]
91+
}
92+
93+
// toExperimentMap converts a map[string]bool to map[experiment.Experiment]bool
94+
func toExperimentMap(m map[string]bool) map[experiment.Experiment]bool {
95+
result := make(map[experiment.Experiment]bool, len(m))
96+
for name, enabled := range m {
97+
result[experiment.Experiment(name)] = enabled
98+
}
99+
return result
100+
}
101+
102+
// formatExperimentMap returns a string representation of the experiments map
103+
// for debug logging, formatted similar to the old slice format.
104+
func formatExperimentMap(m map[experiment.Experiment]bool) string {
105+
if len(m) == 0 {
106+
return "[]"
107+
}
108+
names := make([]string, 0, len(m))
109+
for name, enabled := range m {
110+
if enabled {
111+
names = append(names, string(name))
112+
}
113+
}
114+
sort.Strings(names)
115+
return "[" + strings.Join(names, " ") + "]"
75116
}

internal/config/experiments_test.go

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"context"
2020
"fmt"
2121
"path/filepath"
22-
"slices"
2322
"testing"
2423

2524
"github.com/slackapi/slack-cli/internal/experiment"
@@ -40,7 +39,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
4039
mockOutput, mockPrintDebug := setupMockPrintDebug()
4140

4241
// Write our test script to the memory file system used by the mock
43-
jsonContents := []byte(fmt.Sprintf("{\"last_update_checked_at\":\"2023-05-11T15:41:07.799619-07:00\",\"experiments\":[\"%s\"]}", validExperiment))
42+
jsonContents := []byte(fmt.Sprintf(`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":{"%s":true}}`, validExperiment))
4443
_ = afero.WriteFile(fs, pathToConfigJSON, jsonContents, 0600)
4544

4645
config.LoadExperiments(ctx, mockPrintDebug)
@@ -50,13 +49,34 @@ func Test_Config_WithExperimentOn(t *testing.T) {
5049
fmt.Sprintf("active system experiments: [%s]", validExperiment))
5150
})
5251

52+
t.Run("Returns a parse error with remediation for old array format in config.json", func(t *testing.T) {
53+
// Setup
54+
ctx, fs, _, config, pathToConfigJSON, teardown := setup(t)
55+
defer teardown(t)
56+
mockOutput, mockPrintDebug := setupMockPrintDebug()
57+
58+
// Write old array format
59+
jsonContents := []byte(
60+
fmt.Sprintf(
61+
`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":["%s"]}`,
62+
validExperiment,
63+
),
64+
)
65+
_ = afero.WriteFile(fs, pathToConfigJSON, jsonContents, 0600)
66+
67+
config.LoadExperiments(ctx, mockPrintDebug)
68+
experimentOn := config.WithExperimentOn(validExperiment)
69+
assert.Equal(t, false, experimentOn)
70+
assert.Contains(t, mockOutput.String(), "failed to parse system-level config file")
71+
})
72+
5373
t.Run("Correctly returns false when experiments are not in config.json", func(t *testing.T) {
5474
// Setup
5575
ctx, fs, _, config, pathToConfigJSON, teardown := setup(t)
5676
defer teardown(t)
5777
mockOutput, mockPrintDebug := setupMockPrintDebug()
5878

59-
jsonContents := []byte("{\"last_update_checked_at\":\"2023-05-11T15:41:07.799619-07:00\",\"experiments\":[]}")
79+
jsonContents := []byte(`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":{}}`)
6080
_ = afero.WriteFile(fs, pathToConfigJSON, jsonContents, 0600)
6181

6282
config.LoadExperiments(ctx, mockPrintDebug)
@@ -72,7 +92,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
7292
mockOutput, mockPrintDebug := setupMockPrintDebug()
7393

7494
// Write no contents via config.json
75-
jsonContents := []byte("{\"last_update_checked_at\":\"2023-05-11T15:41:07.799619-07:00\",\"experiments\":[]}")
95+
jsonContents := []byte(`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":{}}`)
7696
_ = afero.WriteFile(fs, pathToConfigJSON, jsonContents, 0600)
7797
config.ExperimentsFlag = []string{string(validExperiment)}
7898

@@ -89,7 +109,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
89109
defer teardown(t)
90110
mockOutput, mockPrintDebug := setupMockPrintDebug()
91111

92-
jsonContents := []byte(fmt.Sprintf("{\"last_update_checked_at\":\"2023-05-11T15:41:07.799619-07:00\",\"experiments\":[\"%s\"]}", invalidExperiment))
112+
jsonContents := []byte(fmt.Sprintf(`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":{"%s":true}}`, invalidExperiment))
93113
_ = afero.WriteFile(fs, pathToConfigJSON, jsonContents, 0600)
94114
config.LoadExperiments(ctx, mockPrintDebug)
95115
assert.Contains(t, mockOutput.String(),
@@ -120,7 +140,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
120140
mockOutput, mockPrintDebug := setupMockPrintDebug()
121141

122142
// Write contents via config.json
123-
jsonContents := []byte(fmt.Sprintf("{\"last_update_checked_at\":\"2023-05-11T15:41:07.799619-07:00\",\"experiments\":[\"%s\"]}", tc.experiment))
143+
jsonContents := []byte(fmt.Sprintf(`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":{"%s":true}}`, tc.experiment))
124144
_ = afero.WriteFile(fs, pathToConfigJSON, jsonContents, 0600)
125145

126146
config.LoadExperiments(ctx, mockPrintDebug)
@@ -172,7 +192,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
172192
require.NoError(t, err)
173193
err = afero.WriteFile(fs, GetProjectHooksJSONFilePath(slackdeps.MockWorkingDirectory), []byte("{}\n"), 0600)
174194
require.NoError(t, err)
175-
jsonContents := []byte(fmt.Sprintf(`{"experiments":["%s"]}`, experiment.Placeholder))
195+
jsonContents := []byte(fmt.Sprintf(`{"experiments":{"%s":true}}`, experiment.Placeholder))
176196
err = afero.WriteFile(fs, GetProjectConfigJSONFilePath(slackdeps.MockWorkingDirectory), []byte(jsonContents), 0600)
177197
require.NoError(t, err)
178198

@@ -185,36 +205,37 @@ func Test_Config_WithExperimentOn(t *testing.T) {
185205
t.Run("Loads valid experiments from project configs and removes duplicates", func(t *testing.T) {
186206
ctx, fs, _, config, _, teardown := setup(t)
187207
defer teardown(t)
188-
mockOutput, mockPrintDebug := setupMockPrintDebug()
208+
_, mockPrintDebug := setupMockPrintDebug()
189209
err := fs.Mkdir(slackdeps.MockWorkingDirectory, 0755)
190210
require.NoError(t, err)
191211
err = fs.Mkdir(filepath.Join(slackdeps.MockWorkingDirectory, ProjectConfigDirName), 0755)
192212
require.NoError(t, err)
193213
err = afero.WriteFile(fs, GetProjectHooksJSONFilePath(slackdeps.MockWorkingDirectory), []byte("{}\n"), 0600)
194214
require.NoError(t, err)
195-
jsonContents := []byte(fmt.Sprintf(
196-
`{"experiments":["%s", "%s", "%s"]}`,
197-
experiment.Placeholder,
198-
experiment.Placeholder,
199-
experiment.Placeholder,
200-
))
215+
jsonContents := []byte(
216+
fmt.Sprintf(`{"experiments":{"%s":true, "%s":true, "%s":true}}`,
217+
experiment.Placeholder,
218+
experiment.Placeholder,
219+
experiment.Placeholder,
220+
),
221+
)
201222
err = afero.WriteFile(fs, GetProjectConfigJSONFilePath(slackdeps.MockWorkingDirectory), []byte(jsonContents), 0600)
202223
require.NoError(t, err)
203224

225+
// Also set via flag to test dedup
226+
config.ExperimentsFlag = []string{string(experiment.Placeholder)}
227+
204228
config.LoadExperiments(ctx, mockPrintDebug)
205229
assert.True(t, config.WithExperimentOn(experiment.Placeholder))
206-
assert.Contains(t, mockOutput.String(), fmt.Sprintf(
207-
"active project experiments: [%s %s %s]",
208-
experiment.Placeholder,
209-
experiment.Placeholder,
210-
experiment.Placeholder,
211-
))
212-
// Assert that duplicates are removed and slice length is reduced
213-
// Add in the permanently enabled experiments before comparing
214-
activeExperiments := append(experiment.EnabledExperiments, experiment.Placeholder)
215-
slices.Sort(activeExperiments)
216-
activeExperiments = slices.Compact(activeExperiments)
217-
assert.Equal(t, activeExperiments, config.experiments)
230+
// Assert that experiments are deduplicated via map
231+
exps := config.GetExperiments()
232+
count := 0
233+
for _, exp := range exps {
234+
if exp == experiment.Placeholder {
235+
count++
236+
}
237+
}
238+
assert.Equal(t, 1, count, "experiment should appear exactly once")
218239
})
219240

220241
t.Run("Loads valid experiments and enabled default experiments", func(t *testing.T) {
@@ -227,7 +248,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
227248
require.NoError(t, err)
228249
err = afero.WriteFile(fs, GetProjectHooksJSONFilePath(slackdeps.MockWorkingDirectory), []byte("{}\n"), 0600)
229250
require.NoError(t, err)
230-
jsonContents := []byte(`{"experiments":[]}`) // No experiments
251+
jsonContents := []byte(`{"experiments":{}}`) // No experiments
231252
err = afero.WriteFile(fs, GetProjectConfigJSONFilePath(slackdeps.MockWorkingDirectory), []byte(jsonContents), 0600)
232253
require.NoError(t, err)
233254

@@ -244,7 +265,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
244265
assert.True(t, config.WithExperimentOn(experiment.Placeholder))
245266
assert.Contains(t, mockOutput.String(), "active project experiments: []")
246267
assert.Contains(t, mockOutput.String(), fmt.Sprintf("active permanently enabled experiments: [%s]", experiment.Placeholder))
247-
assert.Equal(t, []experiment.Experiment{experiment.Placeholder}, config.experiments)
268+
assert.ElementsMatch(t, []experiment.Experiment{experiment.Placeholder}, config.GetExperiments())
248269
})
249270

250271
t.Run("Logs an invalid experiments in project configs", func(t *testing.T) {
@@ -257,7 +278,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
257278
require.NoError(t, err)
258279
err = afero.WriteFile(fs, GetProjectHooksJSONFilePath(slackdeps.MockWorkingDirectory), []byte("{}\n"), 0600)
259280
require.NoError(t, err)
260-
jsonContents := []byte(fmt.Sprintf("{\"experiments\":[\"%s\"]}", "experiment-37"))
281+
jsonContents := []byte(`{"experiments":{"experiment-37":true}}`)
261282
err = afero.WriteFile(fs, GetProjectConfigJSONFilePath(slackdeps.MockWorkingDirectory), []byte(jsonContents), 0600)
262283
require.NoError(t, err)
263284

@@ -283,7 +304,7 @@ func Test_Config_GetExperiments(t *testing.T) {
283304
_, mockPrintDebug := setupMockPrintDebug()
284305

285306
// Write contents via config.json
286-
var configJSON = []byte(fmt.Sprintf("{\"last_update_checked_at\":\"2023-05-11T15:41:07.799619-07:00\",\"experiments\":[\"%s\",\"%s\"]}", validExperiment, validExperiment))
307+
var configJSON = []byte(fmt.Sprintf(`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":{"%s":true}}`, validExperiment))
287308
_ = afero.WriteFile(fs, pathToConfigJSON, configJSON, 0600)
288309

289310
// Set contexts of experiment flag

internal/config/project.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import (
2525
"github.com/google/uuid"
2626
"github.com/opentracing/opentracing-go"
2727
"github.com/slackapi/slack-cli/internal/cache"
28-
"github.com/slackapi/slack-cli/internal/experiment"
2928
"github.com/slackapi/slack-cli/internal/shared/types"
3029
"github.com/slackapi/slack-cli/internal/slackerror"
3130
"github.com/slackapi/slack-cli/internal/style"
@@ -60,7 +59,7 @@ type ProjectConfigManager interface {
6059

6160
// ProjectConfig is the project-level config file
6261
type ProjectConfig struct {
63-
Experiments []experiment.Experiment `json:"experiments,omitempty"`
62+
Experiments map[string]bool `json:"experiments,omitempty"`
6463
Manifest *ManifestConfig `json:"manifest,omitempty"`
6564
ProjectID string `json:"project_id,omitempty"`
6665
Surveys map[string]SurveyConfig `json:"surveys,omitempty"`
@@ -260,7 +259,11 @@ func ReadProjectConfigFile(ctx context.Context, fs afero.Fs, os types.Os) (Proje
260259
return projectConfig, slackerror.New(slackerror.ErrUnableToParseJSON).
261260
WithMessage("Failed to parse contents of project-level config file").
262261
WithRootCause(err).
263-
WithRemediation("Check that %s is valid JSON", style.HomePath(projectConfigFilePath))
262+
WithRemediation(
263+
"Check that %s is valid JSON. %s",
264+
style.HomePath(projectConfigFilePath),
265+
experimentsFormatHint,
266+
)
264267
}
265268
if projectConfig.Surveys == nil {
266269
projectConfig.Surveys = map[string]SurveyConfig{}

internal/config/system.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import (
2626

2727
"github.com/google/uuid"
2828
"github.com/opentracing/opentracing-go"
29-
"github.com/slackapi/slack-cli/internal/experiment"
3029
"github.com/slackapi/slack-cli/internal/shared/types"
3130
"github.com/slackapi/slack-cli/internal/slackerror"
3231
"github.com/slackapi/slack-cli/internal/style"
@@ -63,7 +62,7 @@ type SystemConfigManager interface {
6362

6463
// SystemConfig contains the system-level config file
6564
type SystemConfig struct {
66-
Experiments []experiment.Experiment `json:"experiments,omitempty"`
65+
Experiments map[string]bool `json:"experiments,omitempty"`
6766
LastUpdateCheckedAt time.Time `json:"last_update_checked_at,omitempty"`
6867
Surveys map[string]SurveyConfig `json:"surveys,omitempty"`
6968
SystemID string `json:"system_id,omitempty"`
@@ -133,7 +132,11 @@ func (c *SystemConfig) UserConfig(ctx context.Context) (*SystemConfig, error) {
133132
return &config, slackerror.New(slackerror.ErrUnableToParseJSON).
134133
WithMessage("Failed to parse contents of system-level config file").
135134
WithRootCause(err).
136-
WithRemediation("Check that %s is valid JSON", style.HomePath(path))
135+
WithRemediation(
136+
"Check that %s is valid JSON. %s",
137+
style.HomePath(path),
138+
experimentsFormatHint,
139+
)
137140
}
138141

139142
if config.Surveys == nil {

0 commit comments

Comments
 (0)