Skip to content
Draft
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
3 changes: 1 addition & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
package config

import (
"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/spf13/afero"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -63,7 +62,7 @@ type Config struct {

// Feature experiments
ExperimentsFlag []string
experiments []experiment.Experiment
experiments map[string]bool

// Eventually this will also load the global and project slack config files
DomainAuthTokens string
Expand Down
62 changes: 45 additions & 17 deletions internal/config/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ package config
import (
"context"
"fmt"
"slices"
"maps"
"sort"
"strings"

"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/slackerror"
Expand All @@ -29,47 +31,73 @@ func (c *Config) LoadExperiments(
ctx context.Context,
printDebug func(ctx context.Context, format string, a ...interface{}),
) {
experiments := []experiment.Experiment{}
experiments := map[string]bool{}
// Load from flags
for _, flagValue := range c.ExperimentsFlag {
experiments = append(experiments, experiment.Experiment(flagValue))
experiments[flagValue] = true
}
printDebug(ctx, fmt.Sprintf("active flag experiments: %s", experiments))
printDebug(ctx, fmt.Sprintf("active flag experiments: %s", formatExperimentMap(experiments)))
// Load from project config file
projectConfig, err := ReadProjectConfigFile(ctx, c.fs, c.os)
if err != nil && slackerror.ToSlackError(err).Code != slackerror.ErrInvalidAppDirectory {
printDebug(ctx, fmt.Sprintf("failed to parse project-level config file: %s", err))
} else if err == nil {
printDebug(ctx, fmt.Sprintf("active project experiments: %s", projectConfig.Experiments))
experiments = append(experiments, projectConfig.Experiments...)
printDebug(ctx, fmt.Sprintf("active project experiments: %s", formatExperimentMap(projectConfig.Experiments)))
maps.Copy(experiments, projectConfig.Experiments)
}
// Load from system config file
userConfig, err := c.SystemConfig.UserConfig(ctx)
if err != nil {
printDebug(ctx, fmt.Sprintf("failed to parse system-level config file: %s", err))
} else {
printDebug(ctx, fmt.Sprintf("active system experiments: %s", userConfig.Experiments))
experiments = append(experiments, userConfig.Experiments...)
printDebug(ctx, fmt.Sprintf("active system experiments: %s", formatExperimentMap(userConfig.Experiments)))
maps.Copy(experiments, userConfig.Experiments)
}
// Load from permanently enabled list
experiments = append(experiments, experiment.EnabledExperiments...)
for _, exp := range experiment.EnabledExperiments {
experiments[string(exp)] = true
}
printDebug(ctx, fmt.Sprintf("active permanently enabled experiments: %s", experiment.EnabledExperiments))
// Sort, trim, and audit the experiments list
slices.Sort(experiments)
c.experiments = slices.Compact(experiments)
for _, exp := range c.experiments {
if !experiment.Includes(exp) {
printDebug(ctx, fmt.Sprintf("invalid experiment found: %s", exp))
// Audit the experiments
c.experiments = experiments
for name := range c.experiments {
if !experiment.Includes(experiment.Experiment(name)) {
printDebug(ctx, fmt.Sprintf("invalid experiment found: %s", name))
}
}
}

// GetExperiments returns the set of active experiments
func (c *Config) GetExperiments() []experiment.Experiment {
return c.experiments
result := make([]experiment.Experiment, 0, len(c.experiments))
for name, enabled := range c.experiments {
if enabled {
result = append(result, experiment.Experiment(name))
}
}
sort.Slice(result, func(i, j int) bool {
return result[i] < result[j]
})
return result
}

// WithExperimentOn checks whether an experiment is currently toggled on
func (c *Config) WithExperimentOn(experimentToCheck experiment.Experiment) bool {
return slices.Contains(c.experiments, experimentToCheck)
return c.experiments[string(experimentToCheck)]
}

// formatExperimentMap returns a string representation of the experiments map
// for debug logging, formatted similar to the old slice format.
func formatExperimentMap(m map[string]bool) string {
if len(m) == 0 {
return "[]"
}
names := make([]string, 0, len(m))
for name, enabled := range m {
if enabled {
names = append(names, name)
}
}
sort.Strings(names)
return "[" + strings.Join(names, " ") + "]"
}
71 changes: 40 additions & 31 deletions internal/config/experiments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"context"
"fmt"
"path/filepath"
"slices"
"testing"

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

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

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

t.Run("Correctly finds experiments from old array format in config.json", func(t *testing.T) {
// Setup
ctx, fs, _, config, pathToConfigJSON, teardown := setup(t)
defer teardown(t)
_, mockPrintDebug := setupMockPrintDebug()

// Write old array format
jsonContents := []byte(fmt.Sprintf(`{"last_update_checked_at":"2023-05-11T15:41:07.799619-07:00","experiments":["%s"]}`, validExperiment))
_ = afero.WriteFile(fs, pathToConfigJSON, jsonContents, 0600)

config.LoadExperiments(ctx, mockPrintDebug)
experimentOn := config.WithExperimentOn(validExperiment)
assert.Equal(t, true, experimentOn)
})

t.Run("Correctly returns false when experiments are not in config.json", func(t *testing.T) {
// Setup
ctx, fs, _, config, pathToConfigJSON, teardown := setup(t)
defer teardown(t)
mockOutput, mockPrintDebug := setupMockPrintDebug()

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

config.LoadExperiments(ctx, mockPrintDebug)
Expand All @@ -72,7 +86,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
mockOutput, mockPrintDebug := setupMockPrintDebug()

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

Expand All @@ -89,7 +103,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
defer teardown(t)
mockOutput, mockPrintDebug := setupMockPrintDebug()

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

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

config.LoadExperiments(ctx, mockPrintDebug)
Expand Down Expand Up @@ -172,7 +186,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
require.NoError(t, err)
err = afero.WriteFile(fs, GetProjectHooksJSONFilePath(slackdeps.MockWorkingDirectory), []byte("{}\n"), 0600)
require.NoError(t, err)
jsonContents := []byte(fmt.Sprintf(`{"experiments":["%s"]}`, experiment.Placeholder))
jsonContents := []byte(fmt.Sprintf(`{"experiments":{"%s":true}}`, experiment.Placeholder))
err = afero.WriteFile(fs, GetProjectConfigJSONFilePath(slackdeps.MockWorkingDirectory), []byte(jsonContents), 0600)
require.NoError(t, err)

Expand All @@ -182,39 +196,34 @@ func Test_Config_WithExperimentOn(t *testing.T) {
fmt.Sprintf("active project experiments: [%s]", experiment.Placeholder))
})

t.Run("Loads valid experiments from project configs and removes duplicates", func(t *testing.T) {
t.Run("Loads valid experiments from project configs and deduplicates via map merge", func(t *testing.T) {
ctx, fs, _, config, _, teardown := setup(t)
defer teardown(t)
mockOutput, mockPrintDebug := setupMockPrintDebug()
_, mockPrintDebug := setupMockPrintDebug()
err := fs.Mkdir(slackdeps.MockWorkingDirectory, 0755)
require.NoError(t, err)
err = fs.Mkdir(filepath.Join(slackdeps.MockWorkingDirectory, ProjectConfigDirName), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, GetProjectHooksJSONFilePath(slackdeps.MockWorkingDirectory), []byte("{}\n"), 0600)
require.NoError(t, err)
jsonContents := []byte(fmt.Sprintf(
`{"experiments":["%s", "%s", "%s"]}`,
experiment.Placeholder,
experiment.Placeholder,
experiment.Placeholder,
))
jsonContents := []byte(fmt.Sprintf(`{"experiments":{"%s":true}}`, experiment.Placeholder))
err = afero.WriteFile(fs, GetProjectConfigJSONFilePath(slackdeps.MockWorkingDirectory), []byte(jsonContents), 0600)
require.NoError(t, err)

// Also set via flag to test dedup
config.ExperimentsFlag = []string{string(experiment.Placeholder)}

config.LoadExperiments(ctx, mockPrintDebug)
assert.True(t, config.WithExperimentOn(experiment.Placeholder))
assert.Contains(t, mockOutput.String(), fmt.Sprintf(
"active project experiments: [%s %s %s]",
experiment.Placeholder,
experiment.Placeholder,
experiment.Placeholder,
))
// Assert that duplicates are removed and slice length is reduced
// Add in the permanently enabled experiments before comparing
activeExperiments := append(experiment.EnabledExperiments, experiment.Placeholder)
slices.Sort(activeExperiments)
activeExperiments = slices.Compact(activeExperiments)
assert.Equal(t, activeExperiments, config.experiments)
// Assert that experiments are deduplicated via map
exps := config.GetExperiments()
count := 0
for _, exp := range exps {
if exp == experiment.Placeholder {
count++
}
}
assert.Equal(t, 1, count, "experiment should appear exactly once")
})

t.Run("Loads valid experiments and enabled default experiments", func(t *testing.T) {
Expand All @@ -227,7 +236,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
require.NoError(t, err)
err = afero.WriteFile(fs, GetProjectHooksJSONFilePath(slackdeps.MockWorkingDirectory), []byte("{}\n"), 0600)
require.NoError(t, err)
jsonContents := []byte(`{"experiments":[]}`) // No experiments
jsonContents := []byte(`{"experiments":{}}`) // No experiments
err = afero.WriteFile(fs, GetProjectConfigJSONFilePath(slackdeps.MockWorkingDirectory), []byte(jsonContents), 0600)
require.NoError(t, err)

Expand All @@ -244,7 +253,7 @@ func Test_Config_WithExperimentOn(t *testing.T) {
assert.True(t, config.WithExperimentOn(experiment.Placeholder))
assert.Contains(t, mockOutput.String(), "active project experiments: []")
assert.Contains(t, mockOutput.String(), fmt.Sprintf("active permanently enabled experiments: [%s]", experiment.Placeholder))
assert.Equal(t, []experiment.Experiment{experiment.Placeholder}, config.experiments)
assert.ElementsMatch(t, []experiment.Experiment{experiment.Placeholder}, config.GetExperiments())
})

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

Expand All @@ -283,7 +292,7 @@ func Test_Config_GetExperiments(t *testing.T) {
_, mockPrintDebug := setupMockPrintDebug()

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

// Set contexts of experiment flag
Expand Down
47 changes: 47 additions & 0 deletions internal/config/experiments_unmarshal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2022-2026 Salesforce, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package config

import "encoding/json"

// unmarshalExperimentsField handles backwards-compatible unmarshaling of the
// experiments field. It accepts both the old array format (["charm", "sandboxes"])
// and the new map format ({"charm": true, "sandboxes": true}).
func unmarshalExperimentsField(raw json.RawMessage) map[string]bool {
if len(raw) == 0 {
return nil
}

// Try new map format first
var mapFormat map[string]bool
if err := json.Unmarshal(raw, &mapFormat); err == nil {
return mapFormat
}

// Fall back to old array format
var arrayFormat []string
if err := json.Unmarshal(raw, &arrayFormat); err == nil {
if len(arrayFormat) == 0 {
return nil
}
result := make(map[string]bool, len(arrayFormat))
for _, exp := range arrayFormat {
result[exp] = true
}
return result
}

return nil
}
20 changes: 18 additions & 2 deletions internal/config/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"github.com/google/uuid"
"github.com/opentracing/opentracing-go"
"github.com/slackapi/slack-cli/internal/cache"
"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/internal/style"
Expand Down Expand Up @@ -60,7 +59,7 @@ type ProjectConfigManager interface {

// ProjectConfig is the project-level config file
type ProjectConfig struct {
Experiments []experiment.Experiment `json:"experiments,omitempty"`
Experiments map[string]bool `json:"experiments,omitempty"`
Manifest *ManifestConfig `json:"manifest,omitempty"`
ProjectID string `json:"project_id,omitempty"`
Surveys map[string]SurveyConfig `json:"surveys,omitempty"`
Expand All @@ -72,6 +71,23 @@ type ProjectConfig struct {
os types.Os
}

// UnmarshalJSON implements custom JSON unmarshaling for ProjectConfig to support
// backwards compatibility with the old array format for experiments.
func (c *ProjectConfig) UnmarshalJSON(data []byte) error {
type Alias ProjectConfig
aux := &struct {
RawExperiments json.RawMessage `json:"experiments,omitempty"`
*Alias
}{
Alias: (*Alias)(c),
}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
c.Experiments = unmarshalExperimentsField(aux.RawExperiments)
return nil
}

// NewProjectConfig read and writes to the project-level configuration file
func NewProjectConfig(fs afero.Fs, os types.Os) *ProjectConfig {
projectConfig := &ProjectConfig{
Expand Down
Loading
Loading