diff --git a/cmd/task/task.go b/cmd/task/task.go index 6838654c51..16c8ff190c 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -69,7 +69,7 @@ func run() error { } if flags.Experiments { - return experiments.List(log) + return log.PrintExperiments() } if flags.Init { @@ -109,6 +109,10 @@ func run() error { dir = home } + if err := experiments.Validate(); err != nil { + log.Warnf("%s\n", err.Error()) + } + var taskSorter sort.TaskSorter switch flags.TaskSort { case "none": @@ -154,9 +158,6 @@ func run() error { if err != nil { return err } - if experiments.AnyVariables.Enabled { - log.Warnf("The 'Any Variables' experiment flag is no longer required to use non-map variable types. If you wish to use map variables, please use 'TASK_X_MAP_VARIABLES' instead. See https://github.com/go-task/task/issues/1585\n") - } // If the download flag is specified, we should stop execution as soon as // taskfile is downloaded diff --git a/internal/env/env.go b/internal/env/env.go index 1fbb38172d..5beff7a823 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -8,6 +8,8 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) +const taskVarPrefix = "TASK_" + func Get(t *ast.Task) []string { if t.Env == nil { return nil @@ -23,7 +25,7 @@ func GetFromVars(env *ast.Vars) []string { if !isTypeAllowed(v) { continue } - if !experiments.EnvPrecedence.Enabled { + if !experiments.EnvPrecedence.Enabled() { if _, alreadySet := os.LookupEnv(k); alreadySet { continue } @@ -42,3 +44,7 @@ func isTypeAllowed(v any) bool { return false } } + +func GetTaskEnv(key string) string { + return os.Getenv(taskVarPrefix + key) +} diff --git a/internal/experiments/errors.go b/internal/experiments/errors.go new file mode 100644 index 0000000000..177d6d8608 --- /dev/null +++ b/internal/experiments/errors.go @@ -0,0 +1,32 @@ +package experiments + +import ( + "fmt" + "strings" +) + +type InvalidValueError struct { + Name string + AllowedValues []string + Value string +} + +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, ", "), + ) +} + +type InactiveError struct { + Name string +} + +func (err InactiveError) Error() string { + return fmt.Sprintf( + "task: Experiment %q is inactive and cannot be enabled", + err.Name, + ) +} diff --git a/internal/experiments/experiment.go b/internal/experiments/experiment.go new file mode 100644 index 0000000000..79ad6b5b4e --- /dev/null +++ b/internal/experiments/experiment.go @@ -0,0 +1,56 @@ +package experiments + +import ( + "fmt" + "slices" +) + +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. +} + +// 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) + x := Experiment{ + Name: xName, + AllowedValues: allowedValues, + Value: value, + } + xList = append(xList, x) + return x +} + +func (x *Experiment) Enabled() bool { + return slices.Contains(x.AllowedValues, x.Value) +} + +func (x *Experiment) Active() bool { + return len(x.AllowedValues) > 0 +} + +func (x Experiment) Valid() error { + if !x.Active() && x.Value != "" { + return &InactiveError{ + Name: x.Name, + } + } + if !x.Enabled() && x.Value != "" { + return &InvalidValueError{ + Name: x.Name, + AllowedValues: x.AllowedValues, + Value: x.Value, + } + } + return nil +} + +func (x Experiment) String() string { + if x.Enabled() { + return fmt.Sprintf("on (%s)", x.Value) + } + return "off" +} diff --git a/internal/experiments/experiment_test.go b/internal/experiments/experiment_test.go new file mode 100644 index 0000000000..b9cdcc7cf1 --- /dev/null +++ b/internal/experiments/experiment_test.go @@ -0,0 +1,74 @@ +package experiments_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/go-task/task/v3/internal/experiments" +) + +func TestNew(t *testing.T) { + const ( + exampleExperiment = "EXAMPLE" + exampleExperimentEnv = "TASK_X_EXAMPLE" + ) + tests := []struct { + name string + allowedValues []string + value string + wantEnabled bool + wantActive bool + wantValid error + }{ + { + name: `[] allowed, value=""`, + wantEnabled: false, + wantActive: false, + }, + { + name: `[] allowed, value="1"`, + value: "1", + wantEnabled: false, + wantActive: false, + wantValid: &experiments.InactiveError{ + Name: exampleExperiment, + }, + }, + { + name: `[1] allowed, value=""`, + allowedValues: []string{"1"}, + wantEnabled: false, + wantActive: true, + }, + { + name: `[1] allowed, value="1"`, + allowedValues: []string{"1"}, + value: "1", + wantEnabled: true, + wantActive: true, + }, + { + name: `[1] allowed, value="2"`, + allowedValues: []string{"1"}, + value: "2", + wantEnabled: false, + wantActive: true, + wantValid: &experiments.InvalidValueError{ + Name: exampleExperiment, + AllowedValues: []string{"1"}, + Value: "2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(exampleExperimentEnv, tt.value) + x := experiments.New(exampleExperiment, tt.allowedValues...) + assert.Equal(t, exampleExperiment, x.Name) + assert.Equal(t, tt.wantEnabled, x.Enabled()) + assert.Equal(t, tt.wantActive, x.Active()) + assert.Equal(t, tt.wantValid, x.Valid()) + }) + } +} diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go index 58d2276437..b7abb0a7b9 100644 --- a/internal/experiments/experiments.go +++ b/internal/experiments/experiments.go @@ -2,28 +2,17 @@ package experiments import ( "fmt" - "io" "os" "path/filepath" - "slices" "strings" - "github.com/Ladicle/tabwriter" "github.com/joho/godotenv" "github.com/spf13/pflag" - - "github.com/go-task/task/v3/internal/logger" ) const envPrefix = "TASK_X_" -type Experiment struct { - Name string - Enabled bool - Value string -} - -// A list of experiments. +// A set of experiments that can be enabled or disabled. var ( GentleForce Experiment RemoteTaskfiles Experiment @@ -32,32 +21,31 @@ var ( EnvPrecedence Experiment ) +// An internal list of all the initialized experiments used for iterating. +var xList []Experiment + func init() { readDotEnv() - GentleForce = New("GENTLE_FORCE") - RemoteTaskfiles = New("REMOTE_TASKFILES") - AnyVariables = New("ANY_VARIABLES", "1", "2") + GentleForce = New("GENTLE_FORCE", "1") + RemoteTaskfiles = New("REMOTE_TASKFILES", "1") + AnyVariables = New("ANY_VARIABLES") MapVariables = New("MAP_VARIABLES", "1", "2") - EnvPrecedence = New("ENV_PRECEDENCE") + EnvPrecedence = New("ENV_PRECEDENCE", "1") } -func New(xName string, enabledValues ...string) Experiment { - if len(enabledValues) == 0 { - enabledValues = []string{"1"} - } - value := getEnv(xName) - return Experiment{ - Name: xName, - Enabled: slices.Contains(enabledValues, value), - Value: value, +// Validate checks if any experiments have been enabled while being inactive. +// If one is found, the function returns an error. +func Validate() error { + for _, x := range List() { + if err := x.Valid(); err != nil { + return err + } } + return nil } -func (x Experiment) String() string { - if x.Enabled { - return fmt.Sprintf("on (%s)", x.Value) - } - return "off" +func List() []Experiment { + return xList } func getEnv(xName string) string { @@ -95,18 +83,3 @@ func readDotEnv() { } } } - -func printExperiment(w io.Writer, l *logger.Logger, x Experiment) { - l.FOutf(w, logger.Yellow, "* ") - l.FOutf(w, logger.Green, x.Name) - l.FOutf(w, logger.Default, ": \t%s\n", x.String()) -} - -func List(l *logger.Logger) error { - w := tabwriter.NewWriter(os.Stdout, 0, 8, 0, ' ', 0) - printExperiment(w, l, GentleForce) - printExperiment(w, l, RemoteTaskfiles) - printExperiment(w, l, MapVariables) - printExperiment(w, l, EnvPrecedence) - return w.Flush() -} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 58762c405d..be2227c166 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/pflag" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/experiments" "github.com/go-task/task/v3/taskfile/ast" ) @@ -79,7 +80,7 @@ func init() { log.Print(usage) pflag.PrintDefaults() } - offline, err := strconv.ParseBool(cmp.Or(os.Getenv("TASK_OFFLINE"), "false")) + offline, err := strconv.ParseBool(cmp.Or(env.GetTaskEnv("OFFLINE"), "false")) if err != nil { offline = false } @@ -115,7 +116,7 @@ func init() { pflag.BoolVar(&Experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.") // Gentle force experiment will override the force flag and add a new force-all flag - if experiments.GentleForce.Enabled { + if experiments.GentleForce.Enabled() { pflag.BoolVarP(&Force, "force", "f", false, "Forces execution of the directly called task.") pflag.BoolVar(&ForceAll, "force-all", false, "Forces execution of the called task and all its dependant tasks.") } else { @@ -123,7 +124,7 @@ func init() { } // Remote Taskfiles experiment will adds the "download" and "offline" flags - if experiments.RemoteTaskfiles.Enabled { + if experiments.RemoteTaskfiles.Enabled() { pflag.BoolVar(&Download, "download", false, "Downloads a cached version of a remote Taskfile.") pflag.BoolVar(&Offline, "offline", offline, "Forces Task to only use local or cached Taskfiles.") pflag.DurationVar(&Timeout, "timeout", time.Second*10, "Timeout for downloading remote Taskfiles.") diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 0a276b009c..ad4e7c8f36 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -8,9 +8,12 @@ import ( "strconv" "strings" + "github.com/Ladicle/tabwriter" "github.com/fatih/color" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/env" + "github.com/go-task/task/v3/internal/experiments" "github.com/go-task/task/v3/internal/term" ) @@ -19,70 +22,86 @@ var ( ErrNoTerminal = errors.New("no terminal") ) +var ( + attrsReset = envColor("COLOR_RESET", color.Reset) + attrsFgBlue = envColor("COLOR_BLUE", color.FgBlue) + attrsFgGreen = envColor("COLOR_GREEN", color.FgGreen) + attrsFgCyan = envColor("COLOR_CYAN", color.FgCyan) + attrsFgYellow = envColor("COLOR_YELLOW", color.FgYellow) + attrsFgMagenta = envColor("COLOR_MAGENTA", color.FgMagenta) + attrsFgRed = envColor("COLOR_RED", color.FgRed) + attrsFgHiBlue = envColor("COLOR_BRIGHT_BLUE", color.FgHiBlue) + attrsFgHiGreen = envColor("COLOR_BRIGHT_GREEN", color.FgHiGreen) + attrsFgHiCyan = envColor("COLOR_BRIGHT_CYAN", color.FgHiCyan) + attrsFgHiYellow = envColor("COLOR_BRIGHT_YELLOW", color.FgHiYellow) + attrsFgHiMagenta = envColor("COLOR_BRIGHT_MAGENTA", color.FgHiMagenta) + attrsFgHiRed = envColor("COLOR_BRIGHT_RED", color.FgHiRed) +) + type ( Color func() PrintFunc PrintFunc func(io.Writer, string, ...any) ) func Default() PrintFunc { - return color.New(envColor("TASK_COLOR_RESET", color.Reset)...).FprintfFunc() + return color.New(attrsReset...).FprintfFunc() } func Blue() PrintFunc { - return color.New(envColor("TASK_COLOR_BLUE", color.FgBlue)...).FprintfFunc() + return color.New(attrsFgBlue...).FprintfFunc() } func Green() PrintFunc { - return color.New(envColor("TASK_COLOR_GREEN", color.FgGreen)...).FprintfFunc() + return color.New(attrsFgGreen...).FprintfFunc() } func Cyan() PrintFunc { - return color.New(envColor("TASK_COLOR_CYAN", color.FgCyan)...).FprintfFunc() + return color.New(attrsFgCyan...).FprintfFunc() } func Yellow() PrintFunc { - return color.New(envColor("TASK_COLOR_YELLOW", color.FgYellow)...).FprintfFunc() + return color.New(attrsFgYellow...).FprintfFunc() } func Magenta() PrintFunc { - return color.New(envColor("TASK_COLOR_MAGENTA", color.FgMagenta)...).FprintfFunc() + return color.New(attrsFgMagenta...).FprintfFunc() } func Red() PrintFunc { - return color.New(envColor("TASK_COLOR_RED", color.FgRed)...).FprintfFunc() + return color.New(attrsFgRed...).FprintfFunc() } func BrightBlue() PrintFunc { - return color.New(envColor("TASK_COLOR_BRIGHT_BLUE", color.FgHiBlue)...).FprintfFunc() + return color.New(attrsFgHiBlue...).FprintfFunc() } func BrightGreen() PrintFunc { - return color.New(envColor("TASK_COLOR_BRIGHT_GREEN", color.FgHiGreen)...).FprintfFunc() + return color.New(attrsFgHiGreen...).FprintfFunc() } func BrightCyan() PrintFunc { - return color.New(envColor("TASK_COLOR_BRIGHT_CYAN", color.FgHiCyan)...).FprintfFunc() + return color.New(attrsFgHiCyan...).FprintfFunc() } func BrightYellow() PrintFunc { - return color.New(envColor("TASK_COLOR_BRIGHT_YELLOW", color.FgHiYellow)...).FprintfFunc() + return color.New(attrsFgHiYellow...).FprintfFunc() } func BrightMagenta() PrintFunc { - return color.New(envColor("TASK_COLOR_BRIGHT_MAGENTA", color.FgHiMagenta)...).FprintfFunc() + return color.New(attrsFgHiMagenta...).FprintfFunc() } func BrightRed() PrintFunc { - return color.New(envColor("TASK_COLOR_BRIGHT_RED", color.FgHiRed)...).FprintfFunc() + return color.New(attrsFgHiRed...).FprintfFunc() } -func envColor(env string, defaultColor color.Attribute) []color.Attribute { +func envColor(name string, defaultColor color.Attribute) []color.Attribute { if os.Getenv("FORCE_COLOR") != "" { color.NoColor = false } // Fetch the environment variable - override := os.Getenv(env) + override := env.GetTaskEnv(name) // First, try splitting the string by commas (RGB shortcut syntax) and if it // matches, then prepend the 256-color foreground escape sequence. @@ -195,3 +214,16 @@ func (l *Logger) Prompt(color Color, prompt string, defaultValue string, continu return nil } + +func (l *Logger) PrintExperiments() error { + w := tabwriter.NewWriter(l.Stdout, 0, 8, 0, ' ', 0) + for _, x := range experiments.List() { + if !x.Active() { + continue + } + l.FOutf(w, Yellow, "* ") + l.FOutf(w, Green, x.Name) + l.FOutf(w, Default, ": \t%s\n", x.String()) + } + return w.Flush() +} diff --git a/setup.go b/setup.go index d8d1ffb718..2127518948 100644 --- a/setup.go +++ b/setup.go @@ -14,6 +14,7 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/compiler" + "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/logger" @@ -109,13 +110,14 @@ func (e *Executor) setupTempDir() error { return nil } - if os.Getenv("TASK_TEMP_DIR") == "" { + tempDir := env.GetTaskEnv("TEMP_DIR") + if tempDir == "" { e.TempDir = TempDir{ Remote: filepathext.SmartJoin(e.Dir, ".task"), Fingerprint: filepathext.SmartJoin(e.Dir, ".task"), } - } else if filepath.IsAbs(os.Getenv("TASK_TEMP_DIR")) || strings.HasPrefix(os.Getenv("TASK_TEMP_DIR"), "~") { - tempDir, err := execext.Expand(os.Getenv("TASK_TEMP_DIR")) + } else if filepath.IsAbs(tempDir) || strings.HasPrefix(tempDir, "~") { + tempDir, err := execext.Expand(tempDir) if err != nil { return err } @@ -128,14 +130,15 @@ func (e *Executor) setupTempDir() error { } else { e.TempDir = TempDir{ - Remote: filepathext.SmartJoin(e.Dir, os.Getenv("TASK_TEMP_DIR")), - Fingerprint: filepathext.SmartJoin(e.Dir, os.Getenv("TASK_TEMP_DIR")), + Remote: filepathext.SmartJoin(e.Dir, tempDir), + Fingerprint: filepathext.SmartJoin(e.Dir, tempDir), } } - if os.Getenv("TASK_REMOTE_DIR") != "" { - if filepath.IsAbs(os.Getenv("TASK_REMOTE_DIR")) || strings.HasPrefix(os.Getenv("TASK_REMOTE_DIR"), "~") { - remoteTempDir, err := execext.Expand(os.Getenv("TASK_REMOTE_DIR")) + remoteDir := env.GetTaskEnv("REMOTE_DIR") + if remoteDir != "" { + if filepath.IsAbs(remoteDir) || strings.HasPrefix(remoteDir, "~") { + remoteTempDir, err := execext.Expand(remoteDir) if err != nil { return err } diff --git a/task_test.go b/task_test.go index 3a5b64e95b..b1a7ed4a17 100644 --- a/task_test.go +++ b/task_test.go @@ -3327,12 +3327,11 @@ func TestVarInheritance(t *testing.T) { // because the experiment settings are parsed during experiments.init(), before any tests run. func enableExperimentForTest(t *testing.T, e *experiments.Experiment, val string) { t.Helper() - prev := *e *e = experiments.Experiment{ - Name: prev.Name, - Enabled: true, - Value: val, + Name: prev.Name, + AllowedValues: []string{val}, + Value: val, } t.Cleanup(func() { *e = prev }) } diff --git a/taskfile/ast/var.go b/taskfile/ast/var.go index b2d24977d0..486741a1d6 100644 --- a/taskfile/ast/var.go +++ b/taskfile/ast/var.go @@ -175,7 +175,7 @@ type Var struct { } func (v *Var) UnmarshalYAML(node *yaml.Node) error { - if experiments.MapVariables.Enabled { + if experiments.MapVariables.Enabled() { // This implementation is not backwards-compatible and replaces the 'sh' key with map variables if experiments.MapVariables.Value == "1" { diff --git a/taskfile/node.go b/taskfile/node.go index 74b3245eaf..e9209700bd 100644 --- a/taskfile/node.go +++ b/taskfile/node.go @@ -64,7 +64,7 @@ func NewNode( } - if node.Remote() && !experiments.RemoteTaskfiles.Enabled { + if node.Remote() && !experiments.RemoteTaskfiles.Enabled() { return nil, errors.New("task: Remote taskfiles are not enabled. You can read more about this experiment and how to enable it at https://taskfile.dev/experiments/remote-taskfiles") } return node, err