Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a new .taskrc.yml to enable experiments #1982

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
9 changes: 6 additions & 3 deletions internal/experiments/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ package experiments

import (
"fmt"
"strconv"
"strings"

"github.com/go-task/task/v3/internal/slicesext"
)

type InvalidValueError struct {
Name string
AllowedValues []string
Value string
AllowedValues []int
Value int
}

func (err InvalidValueError) Error() string {
return fmt.Sprintf(
"task: Experiment %q has an invalid value %q (allowed values: %s)",
err.Name,
err.Value,
strings.Join(err.AllowedValues, ", "),
strings.Join(slicesext.Convert(err.AllowedValues, strconv.Itoa), ", "),
)
}

Expand Down
26 changes: 16 additions & 10 deletions internal/experiments/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@ package experiments
import (
"fmt"
"slices"
"strconv"
)

type Experiment struct {
Name string // The name of the experiment.
AllowedValues []string // The values that can enable this experiment.
Value string // The version of the experiment that is enabled.
Name string // The name of the experiment.
AllowedValues []int // The values that can enable this experiment.
Value int // The version of the experiment that is enabled.
}

// New creates a new experiment with the given name and sets the values that can
// enable it.
func New(xName string, allowedValues ...string) Experiment {
value := getEnv(xName)
func New(xName string, allowedValues ...int) Experiment {
value := experimentConfig.Experiments[xName]

if value == 0 {
value, _ = strconv.Atoi(getEnv(xName))
}

x := Experiment{
Name: xName,
AllowedValues: allowedValues,
Expand All @@ -24,21 +30,21 @@ func New(xName string, allowedValues ...string) Experiment {
return x
}

func (x *Experiment) Enabled() bool {
func (x Experiment) Enabled() bool {
return slices.Contains(x.AllowedValues, x.Value)
}

func (x *Experiment) Active() bool {
func (x Experiment) Active() bool {
return len(x.AllowedValues) > 0
}

func (x Experiment) Valid() error {
if !x.Active() && x.Value != "" {
if !x.Active() && x.Value != 0 {
return &InactiveError{
Name: x.Name,
}
}
if !x.Enabled() && x.Value != "" {
if !x.Enabled() && x.Value != 0 {
return &InvalidValueError{
Name: x.Name,
AllowedValues: x.AllowedValues,
Expand All @@ -50,7 +56,7 @@ func (x Experiment) Valid() error {

func (x Experiment) String() string {
if x.Enabled() {
return fmt.Sprintf("on (%s)", x.Value)
return fmt.Sprintf("on (%d)", x.Value)
}
return "off"
}
23 changes: 12 additions & 11 deletions internal/experiments/experiment_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package experiments_test

import (
"strconv"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -15,8 +16,8 @@ func TestNew(t *testing.T) {
)
tests := []struct {
name string
allowedValues []string
value string
allowedValues []int
value int
wantEnabled bool
wantActive bool
wantValid error
Expand All @@ -28,7 +29,7 @@ func TestNew(t *testing.T) {
},
{
name: `[] allowed, value="1"`,
value: "1",
value: 1,
wantEnabled: false,
wantActive: false,
wantValid: &experiments.InactiveError{
Expand All @@ -37,33 +38,33 @@ func TestNew(t *testing.T) {
},
{
name: `[1] allowed, value=""`,
allowedValues: []string{"1"},
allowedValues: []int{1},
wantEnabled: false,
wantActive: true,
},
{
name: `[1] allowed, value="1"`,
allowedValues: []string{"1"},
value: "1",
allowedValues: []int{1},
value: 1,
wantEnabled: true,
wantActive: true,
},
{
name: `[1] allowed, value="2"`,
allowedValues: []string{"1"},
value: "2",
allowedValues: []int{1},
value: 2,
wantEnabled: false,
wantActive: true,
wantValid: &experiments.InvalidValueError{
Name: exampleExperiment,
AllowedValues: []string{"1"},
Value: "2",
AllowedValues: []int{1},
Value: 2,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(exampleExperimentEnv, tt.value)
t.Setenv(exampleExperimentEnv, strconv.Itoa(tt.value))
x := experiments.New(exampleExperiment, tt.allowedValues...)
assert.Equal(t, exampleExperiment, x.Name)
assert.Equal(t, tt.wantEnabled, x.Enabled())
Expand Down
63 changes: 52 additions & 11 deletions internal/experiments/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@ import (
"path/filepath"
"strings"

"github.com/Masterminds/semver/v3"

"gopkg.in/yaml.v3"

"github.com/joho/godotenv"
"github.com/spf13/pflag"
)

const envPrefix = "TASK_X_"

// A set of experiments that can be enabled or disabled.
var defaultConfigFilenames = []string{
".taskrc.yml",
".taskrc.yaml",
}

type experimentConfigFile struct {
Experiments map[string]int `yaml:"experiments"`
Version *semver.Version
}

var (
GentleForce Experiment
RemoteTaskfiles Experiment
Expand All @@ -22,15 +35,19 @@ var (
)

// An internal list of all the initialized experiments used for iterating.
var xList []Experiment
var (
xList []Experiment
experimentConfig experimentConfigFile
)

func init() {
readDotEnv()
GentleForce = New("GENTLE_FORCE", "1")
RemoteTaskfiles = New("REMOTE_TASKFILES", "1")
experimentConfig = readConfig()
GentleForce = New("GENTLE_FORCE", 1)
RemoteTaskfiles = New("REMOTE_TASKFILES", 1)
AnyVariables = New("ANY_VARIABLES")
MapVariables = New("MAP_VARIABLES", "1", "2")
EnvPrecedence = New("ENV_PRECEDENCE", "1")
MapVariables = New("MAP_VARIABLES", 1, 2)
EnvPrecedence = New("ENV_PRECEDENCE", 1)
}

// Validate checks if any experiments have been enabled while being inactive.
Expand All @@ -53,7 +70,7 @@ func getEnv(xName string) string {
return os.Getenv(envName)
}

func getEnvFilePath() string {
func getFilePath(filename string) string {
// Parse the CLI flags again to get the directory/taskfile being run
// We use a flagset here so that we can parse a subset of flags without exiting on error.
var dir, taskfile string
Expand All @@ -64,22 +81,46 @@ func getEnvFilePath() string {
_ = fs.Parse(os.Args[1:])
// If the directory is set, find a .env file in that directory.
if dir != "" {
return filepath.Join(dir, ".env")
return filepath.Join(dir, filename)
}
// If the taskfile is set, find a .env file in the directory containing the Taskfile.
if taskfile != "" {
return filepath.Join(filepath.Dir(taskfile), ".env")
return filepath.Join(filepath.Dir(taskfile), filename)
}
// Otherwise just use the current working directory.
return ".env"
return filename
}

func readDotEnv() {
env, _ := godotenv.Read(getEnvFilePath())
env, _ := godotenv.Read(getFilePath(".env"))
// If the env var is an experiment, set it.
for key, value := range env {
if strings.HasPrefix(key, envPrefix) {
os.Setenv(key, value)
}
}
}

func readConfig() experimentConfigFile {
var cfg experimentConfigFile

var content []byte
var err error
for _, filename := range defaultConfigFilenames {
path := getFilePath(filename)
content, err = os.ReadFile(path)
if err == nil {
break
}
}

if err != nil {
return experimentConfigFile{}
}

if err := yaml.Unmarshal(content, &cfg); err != nil {
return experimentConfigFile{}
}

return cfg
}
12 changes: 12 additions & 0 deletions internal/slicesext/slicesext.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ func UniqueJoin[T cmp.Ordered](ss ...[]T) []T {
slices.Sort(r)
return slices.Compact(r)
}

func Convert[T, U any](s []T, f func(T) U) []U {
// Create a new slice with the same length as the input slice
result := make([]U, len(s))

// Convert each element using the provided function
for i, v := range s {
result[i] = f(v)
}

return result
}
86 changes: 86 additions & 0 deletions internal/slicesext/slicesext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package slicesext

import (
"math"
"strconv"
"testing"
)

func TestConvertIntToString(t *testing.T) {
t.Parallel()
input := []int{1, 2, 3, 4, 5}
expected := []string{"1", "2", "3", "4", "5"}
result := Convert(input, strconv.Itoa)

if len(result) != len(expected) {
t.Errorf("Expected length %d, got %d", len(expected), len(result))
}

for i := range expected {
if result[i] != expected[i] {
t.Errorf("At index %d: expected %v, got %v", i, expected[i], result[i])
}
}
}

func TestConvertStringToInt(t *testing.T) {
t.Parallel()
input := []string{"1", "2", "3", "4", "5"}
expected := []int{1, 2, 3, 4, 5}
result := Convert(input, func(s string) int {
n, _ := strconv.Atoi(s)
return n
})

if len(result) != len(expected) {
t.Errorf("Expected length %d, got %d", len(expected), len(result))
}

for i := range expected {
if result[i] != expected[i] {
t.Errorf("At index %d: expected %v, got %v", i, expected[i], result[i])
}
}
}

func TestConvertFloatToInt(t *testing.T) {
t.Parallel()
input := []float64{1.1, 2.2, 3.7, 4.5, 5.9}
expected := []int{1, 2, 4, 5, 6}
result := Convert(input, func(f float64) int {
return int(math.Round(f))
})

if len(result) != len(expected) {
t.Errorf("Expected length %d, got %d", len(expected), len(result))
}

for i := range expected {
if result[i] != expected[i] {
t.Errorf("At index %d: expected %v, got %v", i, expected[i], result[i])
}
}
}

func TestConvertEmptySlice(t *testing.T) {
t.Parallel()
input := []int{}
result := Convert(input, strconv.Itoa)

if len(result) != 0 {
t.Errorf("Expected empty slice, got length %d", len(result))
}
}

func TestConvertNilSlice(t *testing.T) {
t.Parallel()
var input []int
result := Convert(input, strconv.Itoa)

if result == nil {
t.Error("Expected non-nil empty slice, got nil")
}
if len(result) != 0 {
t.Errorf("Expected empty slice, got length %d", len(result))
}
}
Loading
Loading