diff --git a/executor_test.go b/executor_test.go index e7b8d027..6cea88f8 100644 --- a/executor_test.go +++ b/executor_test.go @@ -351,6 +351,32 @@ func TestRequires(t *testing.T) { ), WithTask("var-defined-in-task"), ) + NewExecutorTest(t, + WithName("enum ref - passes validation"), + WithExecutorOptions( + task.WithDir("testdata/requires"), + ), + WithTask("validation-var-ref"), + WithVar("ENV", "dev"), + ) + NewExecutorTest(t, + WithName("enum ref - fails validation"), + WithExecutorOptions( + task.WithDir("testdata/requires"), + ), + WithTask("validation-var-ref"), + WithVar("ENV", "invalid"), + WithRunError(), + ) + NewExecutorTest(t, + WithName("enum ref - ref to non-list"), + WithExecutorOptions( + task.WithDir("testdata/requires"), + ), + WithTask("validation-var-ref-invalid"), + WithVar("VALUE", "test"), + WithRunError(), + ) } // TODO: mock fs diff --git a/requires.go b/requires.go index 7babdb4d..54c448a7 100644 --- a/requires.go +++ b/requires.go @@ -81,7 +81,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { e.promptedVars = ast.NewVars() for _, v := range varsMap { - value, err := prompter.Prompt(v.Name, v.Enum) + value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum)) if err != nil { if errors.Is(err, input.ErrCancelled) { return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} @@ -120,7 +120,7 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { prompter := e.newPrompter() for _, v := range missing { - value, err := prompter.Prompt(v.Name, v.Enum) + value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum)) if err != nil { if errors.Is(err, input.ErrCancelled) { return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} @@ -168,7 +168,7 @@ func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { for i, v := range missing { missingVars[i] = errors.MissingVar{ Name: v.Name, - AllowedValues: v.Enum, + AllowedValues: getEnumValues(v.Enum), } } @@ -187,11 +187,12 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { for _, requiredVar := range t.Requires.Vars { varValue, _ := t.Vars.Get(requiredVar.Name) + enumValues := getEnumValues(requiredVar.Enum) value, isString := varValue.Value.(string) - if isString && requiredVar.Enum != nil && !slices.Contains(requiredVar.Enum, value) { + if isString && len(enumValues) > 0 && !slices.Contains(enumValues, value) { notAllowedValuesVars = append(notAllowedValuesVars, errors.NotAllowedVar{ Value: value, - Enum: requiredVar.Enum, + Enum: enumValues, Name: requiredVar.Name, }) } @@ -206,3 +207,11 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { return nil } + +// getEnumValues returns the enum values from an Enum struct, or nil if the enum is nil. +func getEnumValues(e *ast.Enum) []string { + if e == nil { + return nil + } + return e.Value +} diff --git a/taskfile/ast/requires.go b/taskfile/ast/requires.go index 5a76e13f..10d6855a 100644 --- a/taskfile/ast/requires.go +++ b/taskfile/ast/requires.go @@ -22,9 +22,53 @@ func (r *Requires) DeepCopy() *Requires { } } +// Enum represents an enum constraint for a required variable. +// It can either be a static list of values or a reference to another variable. +type Enum struct { + Ref string // Reference to a variable containing the allowed values + Value []string // Static list of allowed values +} + +func (e *Enum) DeepCopy() *Enum { + if e == nil { + return nil + } + return &Enum{ + Ref: e.Ref, + Value: deepcopy.Slice(e.Value), + } +} + +// UnmarshalYAML implements yaml.Unmarshaler interface. +func (e *Enum) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.SequenceNode: + // Static list of values: enum: ["a", "b"] + var values []string + if err := node.Decode(&values); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + e.Value = values + return nil + + case yaml.MappingNode: + // Reference to another variable: enum: { ref: .VAR } + var refStruct struct { + Ref string + } + if err := node.Decode(&refStruct); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + e.Ref = refStruct.Ref + return nil + } + + return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("enum") +} + type VarsWithValidation struct { Name string - Enum []string + Enum *Enum } func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { @@ -33,7 +77,7 @@ func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { } return &VarsWithValidation{ Name: v.Name, - Enum: v.Enum, + Enum: v.Enum.DeepCopy(), } } @@ -53,7 +97,7 @@ func (v *VarsWithValidation) UnmarshalYAML(node *yaml.Node) error { case yaml.MappingNode: var vv struct { Name string - Enum []string + Enum *Enum } if err := node.Decode(&vv); err != nil { return errors.NewTaskfileDecodeError(err, node) diff --git a/testdata/requires/Taskfile.yml b/testdata/requires/Taskfile.yml index 0c5f954b..5c9b0cd7 100644 --- a/testdata/requires/Taskfile.yml +++ b/testdata/requires/Taskfile.yml @@ -1,5 +1,9 @@ version: '3' +vars: + ALLOWED_ENVS: ["dev", "staging", "prod"] + NOT_A_LIST: "this is a string" + tasks: default: - task: missing-var @@ -41,3 +45,19 @@ tasks: {{range .MY_VAR | splitList " " }} echo {{.}} {{end}} + + validation-var-ref: + requires: + vars: + - name: ENV + enum: + ref: .ALLOWED_ENVS + cmd: echo "{{.ENV}}" + + validation-var-ref-invalid: + requires: + vars: + - name: VALUE + enum: + ref: .NOT_A_LIST + cmd: echo "{{.VALUE}}" diff --git a/testdata/requires/testdata/TestRequires-enum_ref_-_fails_validation-err-run.golden b/testdata/requires/testdata/TestRequires-enum_ref_-_fails_validation-err-run.golden new file mode 100644 index 00000000..fc20510c --- /dev/null +++ b/testdata/requires/testdata/TestRequires-enum_ref_-_fails_validation-err-run.golden @@ -0,0 +1,2 @@ +task: Task "validation-var-ref" cancelled because it is missing required variables: + - ENV has an invalid value : 'invalid' (allowed values : [dev staging prod]) diff --git a/testdata/requires/testdata/TestRequires-enum_ref_-_fails_validation.golden b/testdata/requires/testdata/TestRequires-enum_ref_-_fails_validation.golden new file mode 100644 index 00000000..e69de29b diff --git a/testdata/requires/testdata/TestRequires-enum_ref_-_passes_validation.golden b/testdata/requires/testdata/TestRequires-enum_ref_-_passes_validation.golden new file mode 100644 index 00000000..5044eab0 --- /dev/null +++ b/testdata/requires/testdata/TestRequires-enum_ref_-_passes_validation.golden @@ -0,0 +1,2 @@ +task: [validation-var-ref] echo "dev" +dev diff --git a/testdata/requires/testdata/TestRequires-enum_ref_-_ref_to_non-list-err-run.golden b/testdata/requires/testdata/TestRequires-enum_ref_-_ref_to_non-list-err-run.golden new file mode 100644 index 00000000..272a4698 --- /dev/null +++ b/testdata/requires/testdata/TestRequires-enum_ref_-_ref_to_non-list-err-run.golden @@ -0,0 +1 @@ +enum reference ".NOT_A_LIST" must resolve to a list \ No newline at end of file diff --git a/testdata/requires/testdata/TestRequires-enum_ref_-_ref_to_non-list.golden b/testdata/requires/testdata/TestRequires-enum_ref_-_ref_to_non-list.golden new file mode 100644 index 00000000..e69de29b diff --git a/variables.go b/variables.go index f2d23aea..f2c13ef4 100644 --- a/variables.go +++ b/variables.go @@ -98,6 +98,17 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err } cache := &templater.Cache{Vars: vars} + + // Resolve enum refs in requires only when evaluateShVars is true, + // since enum refs may depend on shell variables + requires := origTask.Requires + if evaluateShVars { + requires = origTask.Requires.DeepCopy() + if err := resolveEnumRefs(requires, cache); err != nil { + return nil, err + } + } + new := ast.Task{ Task: origTask.Task, Label: templater.Replace(origTask.Label, cache), @@ -125,7 +136,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err Platforms: origTask.Platforms, If: templater.Replace(origTask.If, cache), Location: origTask.Location, - Requires: origTask.Requires, + Requires: requires, Watch: origTask.Watch, Failfast: origTask.Failfast, Namespace: origTask.Namespace, @@ -431,6 +442,32 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) error { return nil } +func resolveEnumRefs(requires *ast.Requires, cache *templater.Cache) error { + if requires == nil || len(requires.Vars) == 0 { + return nil + } + for _, v := range requires.Vars { + if v.Enum == nil || v.Enum.Ref == "" { + continue + } + resolved := templater.ResolveRef(v.Enum.Ref, cache) + arr, ok := resolved.([]any) + if !ok { + return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref) + } + strValues := make([]string, 0, len(arr)) + for _, item := range arr { + s, ok := item.(string) + if !ok { + return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref) + } + strValues = append(strValues, s) + } + v.Enum.Value = strValues + } + return nil +} + // product generates the cartesian product of the input map of slices. func product(matrix *ast.Matrix) []map[string]any { if matrix.Len() == 0 {