Skip to content

Commit 4a81d8e

Browse files
committed
simplify foreign-task-runner fallback: use package-level test hooks instead of App fields
1 parent 1d45014 commit 4a81d8e

4 files changed

Lines changed: 200 additions & 270 deletions

File tree

fallback.go

Lines changed: 80 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -2,159 +2,119 @@ package main
22

33
import (
44
"context"
5-
"errors"
65
"fmt"
7-
"io"
86
"os"
97
"os/exec"
108
"path/filepath"
11-
12-
"github.com/dgageot/gogo/taskfile"
139
)
1410

15-
// fallbackRunner describes an alternative task runner that gogo shells out
16-
// to when no gogo.yaml is found anywhere up the directory tree. Each runner
17-
// is recognised by the presence of one of `files` and invoked as `bin` with
18-
// arguments built by `argsFor`.
19-
type fallbackRunner struct {
20-
bin string
21-
files []string
22-
argsFor func(task string, cliArgs []string) []string
23-
}
11+
// This file implements an opt-in convenience: when gogo can't find a
12+
// gogo.yaml, it looks for a foreign task file (Taskfile / mise.toml) up the
13+
// tree and shells out to the matching tool. It's deliberately self-contained
14+
// and side-tested via the package-level hooks below — the rest of the
15+
// codebase stays oblivious.
16+
17+
// fallbackLookPath / fallbackRun are overridden in tests. Production uses
18+
// exec.LookPath and a real exec.CommandContext.
19+
var (
20+
fallbackLookPath = exec.LookPath
21+
fallbackRun = func(ctx context.Context, name string, argv []string, dir string, app *App) error {
22+
cmd := exec.CommandContext(ctx, name, argv...)
23+
cmd.Dir = dir
24+
cmd.Stdin = os.Stdin
25+
cmd.Stdout = app.Stdout
26+
cmd.Stderr = app.Stderr
27+
if err := cmd.Run(); err != nil {
28+
return fmt.Errorf("%s: %w", name, err)
29+
}
30+
return nil
31+
}
32+
)
2433

25-
// fallbackRunners is the ordered list of runners gogo recognises in the
26-
// absence of a gogo.yaml. Order matters: a directory that contains both a
27-
// Taskfile and a mise.toml is handled by `task`.
28-
var fallbackRunners = []fallbackRunner{
34+
// foreignRunners lists, in priority order, the foreign task files we know
35+
// how to delegate to.
36+
var foreignRunners = []struct {
37+
bin string
38+
files []string
39+
build func(task string, cliArgs []string) []string
40+
}{
2941
{
30-
bin: "task",
31-
files: []string{"Taskfile.yml", "taskfile.yml", "Taskfile.yaml", "taskfile.yaml"},
32-
argsFor: taskArgs,
42+
bin: "task",
43+
files: []string{"Taskfile.yml", "taskfile.yml", "Taskfile.yaml", "taskfile.yaml"},
44+
build: func(task string, cliArgs []string) []string {
45+
return foreignArgs(nil, task, cliArgs)
46+
},
3347
},
3448
{
35-
bin: "mise",
36-
files: []string{"mise.toml"},
37-
argsFor: miseArgs,
49+
bin: "mise",
50+
files: []string{"mise.toml"},
51+
build: func(task string, cliArgs []string) []string {
52+
return foreignArgs([]string{"run"}, task, cliArgs)
53+
},
54+
},
55+
{
56+
// `make` doesn't understand a `--` separator, so trailing args are
57+
// passed as additional positional words — typically extra targets
58+
// or `VAR=value` overrides, which is the conventional invocation.
59+
bin: "make",
60+
files: []string{"Makefile", "makefile", "GNUmakefile"},
61+
build: func(task string, cliArgs []string) []string {
62+
var argv []string
63+
if task != "" && task != "default" {
64+
argv = append(argv, task)
65+
}
66+
return append(argv, cliArgs...)
67+
},
3868
},
3969
}
4070

41-
// taskArgs builds the argv for the `task` CLI. The arg-parser default of
42-
// "default" is dropped so `gogo` (no positional) maps to `task` (no
43-
// positional), letting the underlying runner pick its own default task.
44-
func taskArgs(task string, cliArgs []string) []string {
45-
var args []string
71+
// foreignArgs assembles the argv passed to the foreign tool. The arg-parser
72+
// default of "default" is dropped so `gogo` (no positional) maps to a
73+
// no-positional invocation, letting the underlying tool pick its own default.
74+
func foreignArgs(prefix []string, task string, cliArgs []string) []string {
75+
argv := append([]string{}, prefix...)
4676
if task != "" && task != "default" {
47-
args = append(args, task)
77+
argv = append(argv, task)
4878
}
4979
if len(cliArgs) > 0 {
50-
args = append(args, "--")
51-
args = append(args, cliArgs...)
80+
argv = append(argv, "--")
81+
argv = append(argv, cliArgs...)
5282
}
53-
return args
83+
return argv
5484
}
5585

56-
// miseArgs builds the argv for `mise run`. The "default" task name is dropped
57-
// for the same reason as in taskArgs.
58-
func miseArgs(task string, cliArgs []string) []string {
59-
args := []string{"run"}
60-
if task != "" && task != "default" {
61-
args = append(args, task)
62-
}
63-
if len(cliArgs) > 0 {
64-
args = append(args, "--")
65-
args = append(args, cliArgs...)
86+
// tryForeignFallback walks up from cwd looking for a foreign task file whose
87+
// runner is on PATH. Returns (handled=true, err) when one is invoked; a
88+
// (false, nil) return means the caller should surface the original error.
89+
//
90+
// "Silently ignored if not on PATH" means: if a Taskfile is found but `task`
91+
// isn't installed, we don't try to run it — we keep walking and may pick up
92+
// a sibling/parent runner instead.
93+
func (a *App) tryForeignFallback(ctx context.Context, parsed *args) (bool, error) {
94+
// Tightly coupled to the only error message FindRootDir produces; if
95+
// that ever changes the test suite will catch it.
96+
cwd, err := a.Getwd()
97+
if err != nil {
98+
return false, nil
6699
}
67-
return args
68-
}
69-
70-
// fallbackCandidate pairs a found task file with the runner that should
71-
// handle it.
72-
type fallbackCandidate struct {
73-
runner fallbackRunner
74-
dir string
75-
}
76100

77-
// findFallback walks up from cwd looking for the first directory that
78-
// contains a file recognised by any registered fallback runner whose binary
79-
// is on PATH. A matching file whose runner is not installed is treated as
80-
// absent — "silently ignore" — so a sibling fallback (or a parent dir
81-
// closer to the user) can still apply.
82-
func findFallback(cwd string, lookPath func(string) (string, error)) (fallbackCandidate, bool) {
83101
dir := cwd
84102
for {
85-
for _, r := range fallbackRunners {
86-
if _, err := lookPath(r.bin); err != nil {
103+
for _, r := range foreignRunners {
104+
if _, err := fallbackLookPath(r.bin); err != nil {
87105
continue
88106
}
89107
for _, name := range r.files {
90108
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
91-
return fallbackCandidate{runner: r, dir: dir}, true
109+
argv := r.build(parsed.Task, parsed.CLIArgs)
110+
return true, fallbackRun(ctx, r.bin, argv, dir, a)
92111
}
93112
}
94113
}
95114
parent := filepath.Dir(dir)
96115
if parent == dir {
97-
return fallbackCandidate{}, false
116+
return false, nil
98117
}
99118
dir = parent
100119
}
101120
}
102-
103-
// tryFallback is invoked when loadConfig fails because no gogo.yaml exists.
104-
// It returns (handled=true, err) when a fallback ran; (handled=false, nil)
105-
// means no usable fallback was found and the caller should surface the
106-
// original ErrNoTaskFile.
107-
func (a *App) tryFallback(ctx context.Context, parsed *args) (bool, error) {
108-
cwd, err := a.Getwd()
109-
if err != nil {
110-
return false, err
111-
}
112-
113-
candidate, ok := findFallback(cwd, a.lookPath())
114-
if !ok {
115-
return false, nil
116-
}
117-
118-
argv := candidate.runner.argsFor(parsed.Task, parsed.CLIArgs)
119-
return true, a.runCommand()(ctx, candidate.runner.bin, argv, candidate.dir, a.Stdout, a.Stderr)
120-
}
121-
122-
// lookPath returns the configured LookPath, falling back to exec.LookPath.
123-
// Tests inject a fake to drive the "binary not installed" branch without
124-
// touching the host PATH.
125-
func (a *App) lookPath() func(string) (string, error) {
126-
if a.LookPath != nil {
127-
return a.LookPath
128-
}
129-
return exec.LookPath
130-
}
131-
132-
// runCommand returns the configured RunCommand, falling back to a real
133-
// exec.CommandContext that inherits stdin and writes to the App's streams.
134-
func (a *App) runCommand() func(context.Context, string, []string, string, io.Writer, io.Writer) error {
135-
if a.RunCommand != nil {
136-
return a.RunCommand
137-
}
138-
return defaultRunCommand
139-
}
140-
141-
// defaultRunCommand is the production implementation: it shells out for real
142-
// and propagates the child's exit code as a Go error. Stdin is inherited so
143-
// interactive runners (e.g. `task` prompts) keep working.
144-
func defaultRunCommand(ctx context.Context, name string, args []string, dir string, stdout, stderr io.Writer) error {
145-
cmd := exec.CommandContext(ctx, name, args...)
146-
cmd.Dir = dir
147-
cmd.Stdin = os.Stdin
148-
cmd.Stdout = stdout
149-
cmd.Stderr = stderr
150-
if err := cmd.Run(); err != nil {
151-
return fmt.Errorf("%s: %w", name, err)
152-
}
153-
return nil
154-
}
155-
156-
// isNoTaskFile reports whether err is the sentinel returned by FindRootDir
157-
// when nothing is found while walking up from cwd.
158-
func isNoTaskFile(err error) bool {
159-
return errors.Is(err, taskfile.ErrNoTaskFile)
160-
}

0 commit comments

Comments
 (0)