summaryrefslogtreecommitdiff
path: root/internal/askcli/task_selector.go
blob: 82dc06fd258eb0a7061f0370465b279ab890d3b7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
package askcli

import (
	"context"
	"fmt"
	"io"
	"strings"
)

type resolvedTaskSelector struct {
	Input     string
	UUID      string
	Alias     string
	UsedAlias bool
}

func (d *Dispatcher) resolveTaskSelector(ctx context.Context, selector string, stderr io.Writer) (resolvedTaskSelector, []TaskExport, int, error) {
	normalized, requiresLookup, err := normalizeTaskSelectorInput(selector)
	if err != nil {
		return resolvedTaskSelector{}, nil, 1, err
	}

	resolved, err := resolveTaskSelectorFromCache(normalized, requiresLookup)
	if err != nil {
		return resolvedTaskSelector{}, nil, 1, err
	}

	tasks, code, err := d.exportTasks(ctx, []string{"uuid:" + resolved.UUID, "export"}, stderr)
	if err != nil {
		if resolved.UsedAlias && strings.Contains(err.Error(), "task not found") {
			return resolvedTaskSelector{}, nil, 1, fmt.Errorf("alias %q is stale: task %s was not found", selector, resolved.UUID)
		}
		return resolvedTaskSelector{}, nil, code, err
	}

	aliases, err := ensureTaskAliases(tasks)
	if err != nil {
		return resolvedTaskSelector{}, nil, 1, err
	}
	if alias, ok := aliases[resolved.UUID]; ok {
		resolved.Alias = alias
	}
	return resolved, tasks, 0, nil
}

func normalizeTaskSelectorInput(selector string) (string, bool, error) {
	normalized := NormalizeUUID(selector)
	if selector != normalized && IsNumericID(normalized) {
		return "", false, fmt.Errorf(strings.TrimSpace(RejectNumericID()))
	}
	return normalized, selector == normalized, nil
}

func resolveTaskSelectorFromCache(selector string, allowAlias bool) (resolvedTaskSelector, error) {
	resolved := resolvedTaskSelector{Input: selector, UUID: selector}
	if !allowAlias || !looksLikeTaskAlias(selector) {
		return resolved, nil
	}

	cache, path, err := loadTaskAliasCache()
	if err != nil {
		return resolvedTaskSelector{}, err
	}

	now := nowTaskAliasCache().UTC()
	changed := cache.prune(now)
	uuidFromAlias, aliasFound, aliasChanged := cache.lookupUUIDByAlias(selector, now)
	changed = changed || aliasChanged
	aliasForUUID, uuidFound, uuidChanged := cache.lookupAliasByUUID(selector, now)
	changed = changed || uuidChanged

	switch {
	case aliasFound && uuidFound && uuidFromAlias != selector:
		if changed {
			if err := cache.save(path); err != nil {
				return resolvedTaskSelector{}, err
			}
		}
		return resolvedTaskSelector{}, fmt.Errorf("task selector %q is ambiguous: it matches alias for %s and UUID %s; use uuid:%s to force UUID", selector, uuidFromAlias, selector, selector)
	case aliasFound:
		if changed {
			if err := cache.save(path); err != nil {
				return resolvedTaskSelector{}, err
			}
		}
		return resolvedTaskSelector{
			Input:     selector,
			UUID:      uuidFromAlias,
			Alias:     selector,
			UsedAlias: true,
		}, nil
	case uuidFound:
		if changed {
			if err := cache.save(path); err != nil {
				return resolvedTaskSelector{}, err
			}
		}
		return resolvedTaskSelector{
			Input: selector,
			UUID:  selector,
			Alias: aliasForUUID,
		}, nil
	default:
		if IsNumericID(selector) {
			return resolvedTaskSelector{}, fmt.Errorf(strings.TrimSpace(RejectNumericID()))
		}
		if changed {
			if err := cache.save(path); err != nil {
				return resolvedTaskSelector{}, err
			}
		}
		return resolvedTaskSelector{}, fmt.Errorf("task selector %q did not match a known alias; use uuid:%s to force UUID", selector, selector)
	}
}

func looksLikeTaskAlias(selector string) bool {
	_, ok := decodeTaskAliasID(selector)
	return ok && !strings.Contains(selector, "-")
}

func displayResolvedTaskID(resolved resolvedTaskSelector) string {
	return displayTaskAlias(resolved.UUID, map[string]string{resolved.UUID: resolved.Alias})
}

func displayTaskAlias(uuid string, aliases map[string]string) string {
	if alias := strings.TrimSpace(aliases[uuid]); alias != "" {
		return alias
	}
	return uuid
}

// NormalizeUUID strips any leading "uuid:" prefix so callers can accept
// both "uuid:<value>" and bare UUID strings interchangeably. The returned
// value is always a plain UUID ready to be prefixed again when building
// taskwarrior filter arguments.
func NormalizeUUID(s string) string {
	return strings.TrimPrefix(s, "uuid:")
}

// IsNumericID reports whether the string is entirely numeric.
func IsNumericID(s string) bool {
	if s == "" {
		return false
	}
	for _, c := range s {
		if c < '0' || c > '9' {
			return false
		}
	}
	return true
}

// RejectNumericID returns the error message emitted when numeric Taskwarrior IDs are passed.
func RejectNumericID() string {
	return "error: use a task alias ID or UUID, not a numeric Taskwarrior task ID\n"
}