From 27bdaefbb3a3f83cc32348bc0d89d6c320d8cfd0 Mon Sep 17 00:00:00 2001 From: Walter Scott Date: Tue, 5 Aug 2025 08:11:48 -0500 Subject: [PATCH] test: cover hcl discovery --- completion/fish/task.fish | 2 +- completion/zsh/_task | 4 +-- internal/flags/flags.go | 4 +-- taskfile/discovery_test.go | 62 ++++++++++++++++++++++++++++++++++++++ taskfile/loader.go | 24 +++++++++++---- taskfile/node_http.go | 9 ++++-- taskfile/reader.go | 25 +++++++-------- taskfile/taskfile.go | 41 ++++++++++++++----------- 8 files changed, 129 insertions(+), 42 deletions(-) create mode 100644 taskfile/discovery_test.go diff --git a/completion/fish/task.fish b/completion/fish/task.fish index e8640fe4a5..c54b548ff6 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -48,7 +48,7 @@ complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'executes tasks provided on c complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disables echoing' complete -c $GO_TASK_PROGNAME -l status -d 'exits with non-zero exit code if any of the given tasks is not up-to-date' complete -c $GO_TASK_PROGNAME -l summary -d 'show summary about a task' -complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose which Taskfile to run. Defaults to "Taskfile.yml"' +complete -c $GO_TASK_PROGNAME -s t -l taskfile -d 'choose which Taskfile to run. Defaults to "Taskfile.yml". Also searches for "Taskfile.yaml", "Taskfile.hcl", and "Taskfile"' complete -c $GO_TASK_PROGNAME -s v -l verbose -d 'enables verbose mode' complete -c $GO_TASK_PROGNAME -l version -d 'show Task version' complete -c $GO_TASK_PROGNAME -s w -l watch -d 'enables watch of the given task' diff --git a/completion/zsh/_task b/completion/zsh/_task index ddb888d77b..817c5445f2 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -4,7 +4,7 @@ typeset -A opt_args _GO_TASK_COMPLETION_LIST_OPTION="${GO_TASK_COMPLETION_LIST_OPTION:---list-all}" -# Listing commands from Taskfile.yml +# Listing commands from Taskfile function __task_list() { local -a scripts cmd local -i enabled=0 @@ -19,7 +19,7 @@ function __task_list() { enabled=1 cmd+=(--taskfile "$taskfile") else - for taskfile in {T,t}askfile{,.dist}.{yaml,yml}; do + for taskfile in Taskfile.yml taskfile.yml Taskfile.yaml taskfile.yaml Taskfile.dist.yml taskfile.dist.yml Taskfile.dist.yaml taskfile.dist.yaml Taskfile.hcl taskfile.hcl Taskfile taskfile; do if [[ -f "$taskfile" ]]; then enabled=1 break diff --git a/internal/flags/flags.go b/internal/flags/flags.go index dab9fdf8f4..7dd9e43d15 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -128,7 +128,7 @@ func init() { pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.") pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.") pflag.StringVarP(&Dir, "dir", "d", "", "Sets the directory in which Task will execute and look for a Taskfile.") - pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`) + pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml". Also searches for "Taskfile.yaml", "Taskfile.hcl", and "Taskfile".`) pflag.StringVarP(&Output.Name, "output", "o", "", "Sets output style: [interleaved|group|prefixed].") pflag.StringVar(&Output.Group.Begin, "output-group-begin", "", "Message template to print before a task's grouped output.") pflag.StringVar(&Output.Group.End, "output-group-end", "", "Message template to print after a task's grouped output.") @@ -136,7 +136,7 @@ func init() { pflag.BoolVarP(&Color, "color", "c", true, "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.") pflag.IntVarP(&Concurrency, "concurrency", "C", 0, "Limit number of tasks to run concurrently.") pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.") - pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.") + pflag.BoolVarP(&Global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml,hcl} or $HOME/{T,t}askfile.") 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 diff --git a/taskfile/discovery_test.go b/taskfile/discovery_test.go new file mode 100644 index 0000000000..886cf79adc --- /dev/null +++ b/taskfile/discovery_test.go @@ -0,0 +1,62 @@ +package taskfile + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } + return path +} + +func TestDiscoveryPrefersYAMLOverHCL(t *testing.T) { + dir := t.TempDir() + yamlPath := writeFile(t, dir, "Taskfile.yml", "version: '3'\n") + writeFile(t, dir, "Taskfile.hcl", "version = 3\n") + + node, err := NewFileNode("", dir) + if err != nil { + t.Fatalf("NewFileNode returned error: %v", err) + } + if node.Location() != yamlPath { + t.Fatalf("expected %s, got %s", yamlPath, node.Location()) + } +} + +func TestHCLLoaderInvoked(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "Taskfile.hcl", "version = 3\n") + + node, err := NewFileNode("", dir) + if err != nil { + t.Fatalf("NewFileNode returned error: %v", err) + } + r := NewReader() + _, err = r.Read(context.Background(), node) + if err == nil || !strings.Contains(err.Error(), "HCL parsing not implemented") { + t.Fatalf("expected HCL parsing not implemented error, got %v", err) + } +} + +func TestExtensionlessTaskfile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "Taskfile", "version = 3\n") + + node, err := NewFileNode("", dir) + if err != nil { + t.Fatalf("NewFileNode returned error: %v", err) + } + r := NewReader() + _, err = r.Read(context.Background(), node) + if err == nil || !strings.Contains(err.Error(), "HCL parsing not implemented") { + t.Fatalf("expected HCL parsing not implemented error, got %v", err) + } +} diff --git a/taskfile/loader.go b/taskfile/loader.go index 9fa6805e58..c97bd764be 100644 --- a/taskfile/loader.go +++ b/taskfile/loader.go @@ -1,13 +1,13 @@ package taskfile import ( - stdErrors "errors" + stdErrors "errors" - "gopkg.in/yaml.v3" + "gopkg.in/yaml.v3" - "github.com/go-task/task/v3/errors" - "github.com/go-task/task/v3/internal/filepathext" - "github.com/go-task/task/v3/taskfile/ast" + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/taskfile/ast" ) // Loader defines the behavior required to load a Taskfile from raw data. @@ -18,7 +18,7 @@ import ( // YAML-only helpers. Future work will extract those bindings out of the ast // package so it becomes truly format agnostic. type Loader interface { - Load(data []byte, location string) (*ast.Taskfile, error) + Load(data []byte, location string) (*ast.Taskfile, error) } // YAMLLoader implements [Loader] using YAML as the configuration format. @@ -41,3 +41,15 @@ func (YAMLLoader) Load(data []byte, location string) (*ast.Taskfile, error) { } return &tf, nil } + +// HCLLoader implements [Loader] using HCL as the configuration format. +// +// Note: HCL parsing is not yet implemented. This loader currently returns a +// descriptive error so that discovery logic can recognize HCL Taskfiles without +// breaking program flow. +type HCLLoader struct{} + +// Load returns a not implemented error until HCL support is completed. +func (HCLLoader) Load(data []byte, location string) (*ast.Taskfile, error) { + return nil, errors.New("HCL parsing not implemented") +} diff --git a/taskfile/node_http.go b/taskfile/node_http.go index faa6616db4..8256f2507a 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -17,7 +17,8 @@ import ( // An HTTPNode is a node that reads a Taskfile from a remote location via HTTP. type HTTPNode struct { *baseNode - url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml) + url *url.URL // original URL provided by the user + resolved *url.URL // resolved URL pointing to the actual remote file } func NewHTTPNode( @@ -41,6 +42,9 @@ func NewHTTPNode( } func (node *HTTPNode) Location() string { + if node.resolved != nil { + return node.resolved.Redacted() + } return node.url.Redacted() } @@ -53,6 +57,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { if err != nil { return nil, err } + node.resolved = url req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, errors.TaskfileFetchFailedError{URI: node.Location()} @@ -111,7 +116,7 @@ func (node *HTTPNode) ResolveDir(dir string) (string, error) { } func (node *HTTPNode) CacheKey() string { - checksum := strings.TrimRight(checksum([]byte(node.Location())), "=") + checksum := strings.TrimRight(checksum([]byte(node.url.Redacted())), "=") dir, filename := filepath.Split(node.url.Path) lastDir := filepath.Base(dir) prefix := filename diff --git a/taskfile/reader.go b/taskfile/reader.go index e2c8dbc3b5..8b6708363b 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -60,6 +60,8 @@ func NewReader(opts ...ReaderOption) *Reader { loaders: map[string]Loader{ ".yml": YAMLLoader{}, ".yaml": YAMLLoader{}, + ".hcl": HCLLoader{}, + "": HCLLoader{}, }, insecure: false, download: false, @@ -230,28 +232,27 @@ func (r *Reader) promptf(format string, a ...any) error { } func (r *Reader) include(ctx context.Context, node Node) error { - // Create a new vertex for the Taskfile + // Read and parse the Taskfile from the file + tf, err := r.readNode(ctx, node) + if err != nil { + return err + } + + // Create a new vertex for the Taskfile using the resolved location vertex := &ast.TaskfileVertex{ URI: node.Location(), - Taskfile: nil, + Taskfile: tf, } - // Add the included Taskfile to the DAG - // If the vertex already exists, we return early since its Taskfile has - // already been read and its children explored + // Add the included Taskfile to the DAG. If the vertex already exists, we + // return early since its Taskfile has already been read and its children + // explored if err := r.graph.AddVertex(vertex); err == graph.ErrVertexAlreadyExists { return nil } else if err != nil { return err } - // Read and parse the Taskfile from the file and add it to the vertex - var err error - vertex.Taskfile, err = r.readNode(ctx, node) - if err != nil { - return err - } - // Create an error group to wait for all included Taskfiles to be read var g errgroup.Group diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index e209444acc..044744afdd 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -21,6 +21,10 @@ var ( "taskfile.dist.yml", "Taskfile.dist.yaml", "taskfile.dist.yaml", + "Taskfile.hcl", + "taskfile.hcl", + "Taskfile", + "taskfile", } allowedContentTypes = []string{ "text/plain", @@ -43,25 +47,28 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) { return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} } - // Request the given URL - resp, err := http.DefaultClient.Do(req) - if err != nil { - if ctx.Err() != nil { - return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) + var resp *http.Response + if !strings.HasSuffix(u.Path, "/") { + // Request the given URL + resp, err = http.DefaultClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) + } + return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} } - return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} - } - defer resp.Body.Close() + defer resp.Body.Close() - // If the request was successful and the content type is allowed, return the - // URL The content type check is to avoid downloading files that are not - // Taskfiles It means we can try other files instead of downloading - // something that is definitely not a Taskfile - contentType := resp.Header.Get("Content-Type") - if resp.StatusCode == http.StatusOK && slices.ContainsFunc(allowedContentTypes, func(s string) bool { - return strings.Contains(contentType, s) - }) { - return &u, nil + // If the request was successful and the content type is allowed, return the + // URL. The content type check is to avoid downloading files that are not + // Taskfiles. It means we can try other files instead of downloading + // something that is definitely not a Taskfile. + contentType := resp.Header.Get("Content-Type") + if resp.StatusCode == http.StatusOK && slices.ContainsFunc(allowedContentTypes, func(s string) bool { + return strings.Contains(contentType, s) + }) { + return &u, nil + } } // If the request was not successful, append the default Taskfile names to