mirror of
https://github.com/go-task/task.git
synced 2026-08-29 10:08:27 +02:00
refactor: centralize fingerprint method resolution in a Fingerprinter (#2924)
This commit is contained in:
148
internal/fingerprint/fingerprinter.go
Normal file
148
internal/fingerprint/fingerprinter.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-task/task/v3/internal/logger"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
type (
|
||||
FingerprinterOption func(*Fingerprinter)
|
||||
|
||||
// A Fingerprinter answers whether a task is up-to-date. It owns the
|
||||
// resolution of the fingerprinting method and the checkers behind it.
|
||||
Fingerprinter struct {
|
||||
defaultMethod string
|
||||
tempDir string
|
||||
dry bool
|
||||
logger *logger.Logger
|
||||
statusChecker StatusCheckable
|
||||
sourcesChecker SourcesCheckable
|
||||
}
|
||||
)
|
||||
|
||||
func WithStatusChecker(checker StatusCheckable) FingerprinterOption {
|
||||
return func(f *Fingerprinter) {
|
||||
f.statusChecker = checker
|
||||
}
|
||||
}
|
||||
|
||||
func WithSourcesChecker(checker SourcesCheckable) FingerprinterOption {
|
||||
return func(f *Fingerprinter) {
|
||||
f.sourcesChecker = checker
|
||||
}
|
||||
}
|
||||
|
||||
// NewFingerprinter uses defaultMethod for tasks that don't declare one.
|
||||
func NewFingerprinter(
|
||||
defaultMethod string,
|
||||
tempDir string,
|
||||
dry bool,
|
||||
logger *logger.Logger,
|
||||
opts ...FingerprinterOption,
|
||||
) *Fingerprinter {
|
||||
f := &Fingerprinter{
|
||||
defaultMethod: defaultMethod,
|
||||
tempDir: tempDir,
|
||||
dry: dry,
|
||||
logger: logger,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Fingerprinter) resolveMethod(t *ast.Task) string {
|
||||
if t.Method != "" {
|
||||
return t.Method
|
||||
}
|
||||
return f.defaultMethod
|
||||
}
|
||||
|
||||
// Kind names the fingerprint variable ("checksum", "timestamp" or "none") the
|
||||
// resolved method injects. An invalid method is reported as "checksum" here and
|
||||
// rejected by the entry points that build a checker.
|
||||
func (f *Fingerprinter) Kind(t *ast.Task) string {
|
||||
if f.sourcesChecker != nil {
|
||||
return f.sourcesChecker.Kind()
|
||||
}
|
||||
switch method := f.resolveMethod(t); method {
|
||||
case "timestamp", "none":
|
||||
return method
|
||||
default:
|
||||
return "checksum"
|
||||
}
|
||||
}
|
||||
|
||||
// SourceValue returns the value of the fingerprint variable for the given task.
|
||||
// It is potentially expensive, so only call it when the task references it.
|
||||
func (f *Fingerprinter) SourceValue(t *ast.Task) (any, error) {
|
||||
sourcesChecker, err := f.resolveSourcesChecker(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sourcesChecker.Value(t)
|
||||
}
|
||||
|
||||
// UpToDate considers both the status commands and the sources of a task; one
|
||||
// that declares neither never is.
|
||||
func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) {
|
||||
var statusUpToDate bool
|
||||
var sourcesUpToDate bool
|
||||
|
||||
statusChecker := f.statusChecker
|
||||
if statusChecker == nil {
|
||||
statusChecker = NewStatusChecker(f.logger)
|
||||
}
|
||||
sourcesChecker, err := f.resolveSourcesChecker(t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
statusIsSet := len(t.Status) != 0
|
||||
sourcesIsSet := len(t.Sources) != 0
|
||||
|
||||
if statusIsSet {
|
||||
statusUpToDate, err = statusChecker.IsUpToDate(ctx, t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if sourcesIsSet {
|
||||
sourcesUpToDate, err = sourcesChecker.IsUpToDate(t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if statusIsSet && sourcesIsSet {
|
||||
return statusUpToDate && sourcesUpToDate, nil
|
||||
}
|
||||
if statusIsSet {
|
||||
return statusUpToDate, nil
|
||||
}
|
||||
if sourcesIsSet {
|
||||
return sourcesUpToDate, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// OnError lets the resolved sources checker clean up after a failed run.
|
||||
func (f *Fingerprinter) OnError(t *ast.Task) error {
|
||||
sourcesChecker, err := f.resolveSourcesChecker(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sourcesChecker.OnError(t)
|
||||
}
|
||||
|
||||
// resolveSourcesChecker is the single place where a task is mapped to a checker.
|
||||
func (f *Fingerprinter) resolveSourcesChecker(t *ast.Task) (SourcesCheckable, error) {
|
||||
if f.sourcesChecker != nil {
|
||||
return f.sourcesChecker, nil
|
||||
}
|
||||
return NewSourcesChecker(f.resolveMethod(t), f.tempDir, f.dry)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -23,7 +26,7 @@ import (
|
||||
// | false | not set | false |
|
||||
// | false | true | false |
|
||||
// | false | false | false |
|
||||
func TestIsTaskUpToDate(t *testing.T) {
|
||||
func TestFingerprinterUpToDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -162,14 +165,94 @@ func TestIsTaskUpToDate(t *testing.T) {
|
||||
tt.setupMockSourcesChecker(mockSourcesChecker)
|
||||
}
|
||||
|
||||
result, err := IsTaskUpToDate(
|
||||
t.Context(),
|
||||
tt.task,
|
||||
f := NewFingerprinter("checksum", "", false, nil,
|
||||
WithStatusChecker(mockStatusChecker),
|
||||
WithSourcesChecker(mockSourcesChecker),
|
||||
)
|
||||
result, err := f.UpToDate(t.Context(), tt.task)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The task's own method wins over the Taskfile default, for the injected
|
||||
// variable as much as for the up-to-date check.
|
||||
func TestFingerprinterMethodResolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultMethod string
|
||||
method string
|
||||
expectedKind string
|
||||
expectedValue any
|
||||
}{
|
||||
{
|
||||
name: "task method wins over the default",
|
||||
defaultMethod: "checksum",
|
||||
method: "timestamp",
|
||||
expectedKind: "timestamp",
|
||||
expectedValue: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "default method is inherited when the task declares none",
|
||||
defaultMethod: "timestamp",
|
||||
expectedKind: "timestamp",
|
||||
expectedValue: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "checksum is inherited too",
|
||||
defaultMethod: "checksum",
|
||||
expectedKind: "checksum",
|
||||
expectedValue: "",
|
||||
},
|
||||
{
|
||||
name: "none is inherited too",
|
||||
defaultMethod: "none",
|
||||
expectedKind: "none",
|
||||
expectedValue: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "source.txt"), []byte("content"), 0o644))
|
||||
task := &ast.Task{
|
||||
Dir: dir,
|
||||
Method: tt.method,
|
||||
Sources: []*ast.Glob{{Glob: "source.txt"}},
|
||||
}
|
||||
|
||||
f := NewFingerprinter(tt.defaultMethod, t.TempDir(), true, nil)
|
||||
|
||||
assert.Equal(t, tt.expectedKind, f.Kind(task))
|
||||
|
||||
// A timestamp checker yields a time, the other two a string.
|
||||
value, err := f.SourceValue(task)
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, tt.expectedValue, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Only the entry points that need a checker reject an invalid method; Kind
|
||||
// tolerates it, so that --force runs still compile.
|
||||
func TestFingerprinterInvalidMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const wantErr = `task: invalid method "Checksum"`
|
||||
task := &ast.Task{Sources: []*ast.Glob{{Glob: "source.txt"}}}
|
||||
f := NewFingerprinter("Checksum", t.TempDir(), true, nil)
|
||||
|
||||
assert.Equal(t, "checksum", f.Kind(task))
|
||||
|
||||
_, err := f.SourceValue(task)
|
||||
require.ErrorIs(t, err, ErrInvalidMethod)
|
||||
require.EqualError(t, err, wantErr)
|
||||
_, err = f.UpToDate(t.Context(), task)
|
||||
require.EqualError(t, err, wantErr)
|
||||
require.EqualError(t, f.OnError(task), wantErr)
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
package fingerprint
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-task/task/v3/errors"
|
||||
)
|
||||
|
||||
// ErrInvalidMethod lets callers that only need a fingerprint value tell a bad
|
||||
// method name apart from a checker failing on the sources themselves.
|
||||
var ErrInvalidMethod = errors.New("invalid method")
|
||||
|
||||
func NewSourcesChecker(method, tempDir string, dry bool) (SourcesCheckable, error) {
|
||||
switch method {
|
||||
@@ -11,6 +19,6 @@ func NewSourcesChecker(method, tempDir string, dry bool) (SourcesCheckable, erro
|
||||
case "none":
|
||||
return NoneChecker{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf(`task: invalid method "%s"`, method)
|
||||
return nil, fmt.Errorf(`task: %w "%s"`, ErrInvalidMethod, method)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-task/task/v3/internal/logger"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
type (
|
||||
CheckerOption func(*CheckerConfig)
|
||||
CheckerConfig struct {
|
||||
method string
|
||||
dry bool
|
||||
tempDir string
|
||||
logger *logger.Logger
|
||||
statusChecker StatusCheckable
|
||||
sourcesChecker SourcesCheckable
|
||||
}
|
||||
)
|
||||
|
||||
func WithMethod(method string) CheckerOption {
|
||||
return func(config *CheckerConfig) {
|
||||
config.method = method
|
||||
}
|
||||
}
|
||||
|
||||
func WithDry(dry bool) CheckerOption {
|
||||
return func(config *CheckerConfig) {
|
||||
config.dry = dry
|
||||
}
|
||||
}
|
||||
|
||||
func WithTempDir(tempDir string) CheckerOption {
|
||||
return func(config *CheckerConfig) {
|
||||
config.tempDir = tempDir
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(logger *logger.Logger) CheckerOption {
|
||||
return func(config *CheckerConfig) {
|
||||
config.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
func WithStatusChecker(checker StatusCheckable) CheckerOption {
|
||||
return func(config *CheckerConfig) {
|
||||
config.statusChecker = checker
|
||||
}
|
||||
}
|
||||
|
||||
func WithSourcesChecker(checker SourcesCheckable) CheckerOption {
|
||||
return func(config *CheckerConfig) {
|
||||
config.sourcesChecker = checker
|
||||
}
|
||||
}
|
||||
|
||||
func IsTaskUpToDate(
|
||||
ctx context.Context,
|
||||
t *ast.Task,
|
||||
opts ...CheckerOption,
|
||||
) (bool, error) {
|
||||
var statusUpToDate bool
|
||||
var sourcesUpToDate bool
|
||||
var err error
|
||||
|
||||
// Default config
|
||||
config := &CheckerConfig{
|
||||
method: "none",
|
||||
tempDir: "",
|
||||
dry: false,
|
||||
logger: nil,
|
||||
statusChecker: nil,
|
||||
sourcesChecker: nil,
|
||||
}
|
||||
|
||||
// Apply functional options
|
||||
for _, opt := range opts {
|
||||
opt(config)
|
||||
}
|
||||
|
||||
// If no status checker was given, set up the default one
|
||||
if config.statusChecker == nil {
|
||||
config.statusChecker = NewStatusChecker(config.logger)
|
||||
}
|
||||
|
||||
// If no sources checker was given, set up the default one
|
||||
if config.sourcesChecker == nil {
|
||||
config.sourcesChecker, err = NewSourcesChecker(config.method, config.tempDir, config.dry)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
statusIsSet := len(t.Status) != 0
|
||||
sourcesIsSet := len(t.Sources) != 0
|
||||
|
||||
// If status is set, check if it is up-to-date
|
||||
if statusIsSet {
|
||||
statusUpToDate, err = config.statusChecker.IsUpToDate(ctx, t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// If sources is set, check if they are up-to-date
|
||||
if sourcesIsSet {
|
||||
sourcesUpToDate, err = config.sourcesChecker.IsUpToDate(t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// If both status and sources are set, the task is up-to-date if both are up-to-date
|
||||
if statusIsSet && sourcesIsSet {
|
||||
return statusUpToDate && sourcesUpToDate, nil
|
||||
}
|
||||
|
||||
// If only status is set, the task is up-to-date if the status is up-to-date
|
||||
if statusIsSet {
|
||||
return statusUpToDate, nil
|
||||
}
|
||||
|
||||
// If only sources is set, the task is up-to-date if the sources are up-to-date
|
||||
if sourcesIsSet {
|
||||
return sourcesUpToDate, nil
|
||||
}
|
||||
|
||||
// If no status or sources are set, the task should always run
|
||||
// i.e. it is never considered "up-to-date"
|
||||
return false, nil
|
||||
}
|
||||
Reference in New Issue
Block a user