diff --git a/internal/experiments/errors.go b/internal/experiments/errors.go index 177d6d86..68a5f8b8 100644 --- a/internal/experiments/errors.go +++ b/internal/experiments/errors.go @@ -2,13 +2,16 @@ 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 { @@ -16,7 +19,7 @@ func (err InvalidValueError) Error() string { "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), ", "), ) } diff --git a/internal/experiments/experiment.go b/internal/experiments/experiment.go index 79ad6b5b..07bd22aa 100644 --- a/internal/experiments/experiment.go +++ b/internal/experiments/experiment.go @@ -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, @@ -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, @@ -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" } diff --git a/internal/experiments/experiment_test.go b/internal/experiments/experiment_test.go index b9cdcc7c..f953f92f 100644 --- a/internal/experiments/experiment_test.go +++ b/internal/experiments/experiment_test.go @@ -1,6 +1,7 @@ package experiments_test import ( + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -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 @@ -28,7 +29,7 @@ func TestNew(t *testing.T) { }, { name: `[] allowed, value="1"`, - value: "1", + value: 1, wantEnabled: false, wantActive: false, wantValid: &experiments.InactiveError{ @@ -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()) diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go index b7abb0a7..6235a039 100644 --- a/internal/experiments/experiments.go +++ b/internal/experiments/experiments.go @@ -6,13 +6,24 @@ import ( "path/filepath" "strings" + "github.com/Masterminds/semver/v3" "github.com/joho/godotenv" "github.com/spf13/pflag" + "gopkg.in/yaml.v3" ) 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 @@ -22,15 +33,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. @@ -53,7 +68,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 @@ -64,18 +79,18 @@ 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) { @@ -83,3 +98,27 @@ func readDotEnv() { } } } + +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 +} diff --git a/internal/slicesext/slicesext.go b/internal/slicesext/slicesext.go index 8b539bc8..2aba5beb 100644 --- a/internal/slicesext/slicesext.go +++ b/internal/slicesext/slicesext.go @@ -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 +} diff --git a/internal/slicesext/slicesext_test.go b/internal/slicesext/slicesext_test.go new file mode 100644 index 00000000..72fa5332 --- /dev/null +++ b/internal/slicesext/slicesext_test.go @@ -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)) + } +} diff --git a/task_test.go b/task_test.go index dabdef98..ff407f18 100644 --- a/task_test.go +++ b/task_test.go @@ -135,7 +135,7 @@ func TestEnv(t *testing.T) { }, } tt.Run(t) - enableExperimentForTest(t, &experiments.EnvPrecedence, "1") + enableExperimentForTest(t, &experiments.EnvPrecedence, 1) ttt := fileContentTest{ Dir: "testdata/env", Target: "overridden", @@ -1215,7 +1215,7 @@ func TestIncludesMultiLevel(t *testing.T) { } func TestIncludesRemote(t *testing.T) { - enableExperimentForTest(t, &experiments.RemoteTaskfiles, "1") + enableExperimentForTest(t, &experiments.RemoteTaskfiles, 1) dir := "testdata/includes_remote" @@ -1373,7 +1373,7 @@ func TestIncludesEmptyMain(t *testing.T) { } func TestIncludesHttp(t *testing.T) { - enableExperimentForTest(t, &experiments.RemoteTaskfiles, "1") + enableExperimentForTest(t, &experiments.RemoteTaskfiles, 1) dir, err := filepath.Abs("testdata/includes_http") require.NoError(t, err) @@ -3224,7 +3224,7 @@ func TestReference(t *testing.T) { } func TestVarInheritance(t *testing.T) { - enableExperimentForTest(t, &experiments.EnvPrecedence, "1") + enableExperimentForTest(t, &experiments.EnvPrecedence, 1) tests := []struct { name string want string @@ -3332,12 +3332,12 @@ func TestVarInheritance(t *testing.T) { // // Typically experiments are controlled via TASK_X_ env vars, but we cannot use those in tests // because the experiment settings are parsed during experiments.init(), before any tests run. -func enableExperimentForTest(t *testing.T, e *experiments.Experiment, val string) { +func enableExperimentForTest(t *testing.T, e *experiments.Experiment, val int) { t.Helper() prev := *e *e = experiments.Experiment{ Name: prev.Name, - AllowedValues: []string{val}, + AllowedValues: []int{val}, Value: val, } t.Cleanup(func() { *e = prev }) diff --git a/taskfile/ast/var.go b/taskfile/ast/var.go index 4237412d..abe5157d 100644 --- a/taskfile/ast/var.go +++ b/taskfile/ast/var.go @@ -22,7 +22,7 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error { if experiments.MapVariables.Enabled() { // This implementation is not backwards-compatible and replaces the 'sh' key with map variables - if experiments.MapVariables.Value == "1" { + if experiments.MapVariables.Value == 1 { var value any if err := node.Decode(&value); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -43,7 +43,7 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error { } // This implementation IS backwards-compatible and keeps the 'sh' key and allows map variables to be added under the `map` key - if experiments.MapVariables.Value == "2" { + if experiments.MapVariables.Value == 2 { switch node.Kind { case yaml.MappingNode: key := node.Content[0].Value diff --git a/website/docs/experiments/experiments.mdx b/website/docs/experiments/experiments.mdx index 8a665ce8..f8850ebf 100644 --- a/website/docs/experiments/experiments.mdx +++ b/website/docs/experiments/experiments.mdx @@ -3,6 +3,9 @@ slug: /experiments/ sidebar_position: 6 --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # Experiments :::caution @@ -39,7 +42,7 @@ Which method you use depends on how you intend to use the experiment: 1. Prefixing your task commands with the relevant environment variable(s). For example, `TASK_X_{FEATURE}=1 task {my-task}`. This is intended for one-off invocations of Task to test out experimental features. -1. Adding the relevant environment variable(s) in your "dotfiles" (e.g. +2. Adding the relevant environment variable(s) in your "dotfiles" (e.g. `.bashrc`, `.zshrc` etc.). This will permanently enable experimental features for your personal environment. @@ -47,15 +50,33 @@ Which method you use depends on how you intend to use the experiment: export TASK_X_FEATURE=1 ``` -1. Creating a `.env` file in the same directory as your root Taskfile that - contains the relevant environment variable(s). This allows you to enable an - experimental feature at a project level. If you commit the `.env` file to - source control then other users of your project will also have these - experiments enabled. +3. Creating a `.env` or a `.task-experiments.yml` file in the same directory as + your root Taskfile.\ + The `.env` file should contain the relevant environment + variable(s), while the `.task-experiments.yml` file should use a YAML format + where each experiment is defined as a key with a corresponding value. - ```shell title=".env" - TASK_X_FEATURE=1 - ``` + This allows you to enable an experimental feature at a project level. If you + commit this file to source control, then other users of your project will + also have these experiments enabled. + + If both files are present, the values in the `.task-experiments.yml` file + will take precedence. + + + + ```yaml title=".taskrc.yml" + experiments: + FEATURE: 1 + ``` + + + + ```shell title=".env" + TASK_X_FEATURE=1 + ``` + + ## Workflow diff --git a/website/static/schema-taskrc.json b/website/static/schema-taskrc.json new file mode 100644 index 00000000..4ed35be3 --- /dev/null +++ b/website/static/schema-taskrc.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Taskrc YAML Schema", + "description": "Schema for .taskrc files.", + "type": "object", + "properties": { + "experiments": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + }, + "additionalProperties": false +}