summaryrefslogtreecommitdiff
path: root/internal/askcli/command_complete_uuids_test.go
blob: 2c1b5fdfd3559a8e0f2a526d7937e5deee12fab0 (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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
package askcli

import (
	"bytes"
	"context"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) {
	dir := t.TempDir()
	oldNow := nowTaskAliasCache
	oldRoot := taskAliasCacheRoot
	nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
	taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
	defer func() {
		nowTaskAliasCache = oldNow
		taskAliasCacheRoot = oldRoot
	}()

	d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
		want := []string{"status:pending", "export"}
		if strings.Join(args, " ") != strings.Join(want, " ") {
			t.Fatalf("args = %v, want %v", args, want)
		}
		_, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"First task"},{"uuid":"uuid-2","description":"Second task"},{"uuid":""}]`)
		return 0, nil
	}})

	var stdout, stderr bytes.Buffer
	code, err := d.handleCompleteUUIDs(context.Background(), nil, &stdout, &stderr)
	if err != nil {
		t.Fatalf("handleCompleteUUIDs returned error: %v", err)
	}
	if code != 0 {
		t.Fatalf("handleCompleteUUIDs code = %d, want 0", code)
	}
	// Each line is "selector\tdescription" so fish shell shows the task
	// summary alongside the alias/UUID in the autocompletion menu.
	if got := stdout.String(); got != "0\tFirst task\nuuid-1\tFirst task\n1\tSecond task\nuuid-2\tSecond task\n" {
		t.Fatalf("stdout = %q, want tab-separated selector+description list", got)
	}
	if stderr.Len() != 0 {
		t.Fatalf("stderr = %q, want empty", stderr.String())
	}

	path, err := taskAliasCachePath()
	if err != nil {
		t.Fatalf("taskAliasCachePath: %v", err)
	}
	cache := readTaskAliasCacheForTest(t, path)
	if got := findTaskAliasEntry(t, cache, "uuid-1").Alias; got != "0" {
		t.Fatalf("uuid-1 alias = %q, want 0", got)
	}
	if got := findTaskAliasEntry(t, cache, "uuid-2").Alias; got != "1" {
		t.Fatalf("uuid-2 alias = %q, want 1", got)
	}
}

func TestHandleCompleteUUIDs_ParseError(t *testing.T) {
	d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
		_, _ = io.WriteString(stdout, `not-json`)
		return 0, nil
	}})

	var stdout, stderr bytes.Buffer
	code, err := d.handleCompleteUUIDs(context.Background(), nil, &stdout, &stderr)
	if err != nil {
		t.Fatalf("handleCompleteUUIDs returned error: %v", err)
	}
	if code != 1 {
		t.Fatalf("handleCompleteUUIDs code = %d, want 1", code)
	}
	if !strings.Contains(stderr.String(), "failed to parse task data") {
		t.Fatalf("stderr = %q, want parse error", stderr.String())
	}
}

func TestHandleCompleteUUIDs_RecoverFromCorruptAliasCache(t *testing.T) {
	dir := t.TempDir()
	oldRoot := taskAliasCacheRoot
	taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
	defer func() { taskAliasCacheRoot = oldRoot }()

	path, err := taskAliasCachePath()
	if err != nil {
		t.Fatalf("taskAliasCachePath: %v", err)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("MkdirAll: %v", err)
	}
	// Simulate a corrupted cache file (e.g. two JSON objects concatenated from a
	// concurrent write race). The handler must recover by resetting the cache and
	// assigning fresh aliases rather than erroring or degrading to UUID-only output.
	if err := os.WriteFile(path, []byte("{bad"), 0o600); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}

	d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
		_, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Fallback task"}]`)
		return 0, nil
	}})

	var stdout, stderr bytes.Buffer
	code, err := d.handleCompleteUUIDs(context.Background(), nil, &stdout, &stderr)
	if err != nil {
		t.Fatalf("handleCompleteUUIDs returned error: %v", err)
	}
	if code != 0 {
		t.Fatalf("handleCompleteUUIDs code = %d, want 0", code)
	}
	// After recovery a fresh alias (e.g. "0") must be assigned, so the output
	// includes both the short alias and the UUID (fish shows whichever the user
	// types). No warning should appear on stderr.
	got := stdout.String()
	if !strings.Contains(got, "uuid-1\tFallback task") {
		t.Fatalf("stdout = %q, want UUID with description", got)
	}
	if !strings.Contains(got, "Fallback task") {
		t.Fatalf("stdout = %q, want task description in output", got)
	}
	if stderr.Len() != 0 {
		t.Fatalf("stderr = %q, want no warnings after graceful recovery", stderr.String())
	}
}

func TestTaskCompletionSelectors_SkipsMissingAndDuplicateAliases(t *testing.T) {
	tasks := []TaskExport{
		{UUID: "uuid-1"},
		{UUID: ""},
		{UUID: "uuid-2"},
	}
	aliases := map[string]string{
		"uuid-1": "0",
		"uuid-2": "uuid-2",
	}

	got := taskCompletionSelectors(tasks, aliases)
	want := []string{"0", "uuid-1", "uuid-2"}
	if strings.Join(got, "\n") != strings.Join(want, "\n") {
		t.Fatalf("taskCompletionSelectors = %v, want %v", got, want)
	}
}

func TestTaskCompletionAliasItems_OnlyShortAliases(t *testing.T) {
	tasks := []TaskExport{
		{UUID: "uuid-1", Description: "First task"},
		{UUID: "", Description: "Ignored"},
		{UUID: "uuid-2", Description: "Second task"},
	}
	aliases := map[string]string{
		"uuid-1": "0",
		"uuid-2": "uuid-2", // same as UUID: no alias-only line (use complete-uuids for UUID)
	}

	got := taskCompletionAliasItems(tasks, aliases)
	want := []string{
		"0\tFirst task",
	}
	if strings.Join(got, "\n") != strings.Join(want, "\n") {
		t.Fatalf("taskCompletionAliasItems = %v, want %v", got, want)
	}
}

func TestHandleCompleteAliases_PrintsAliasesOnly(t *testing.T) {
	dir := t.TempDir()
	oldNow := nowTaskAliasCache
	oldRoot := taskAliasCacheRoot
	nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
	taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
	defer func() {
		nowTaskAliasCache = oldNow
		taskAliasCacheRoot = oldRoot
	}()

	d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
		want := []string{"status:pending", "export"}
		if strings.Join(args, " ") != strings.Join(want, " ") {
			t.Fatalf("args = %v, want %v", args, want)
		}
		_, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"First task"},{"uuid":"uuid-2","description":"Second task"},{"uuid":""}]`)
		return 0, nil
	}})

	var stdout, stderr bytes.Buffer
	code, err := d.handleCompleteAliases(context.Background(), nil, &stdout, &stderr)
	if err != nil {
		t.Fatalf("handleCompleteAliases returned error: %v", err)
	}
	if code != 0 {
		t.Fatalf("handleCompleteAliases code = %d, want 0", code)
	}
	if got := stdout.String(); got != "0\tFirst task\n1\tSecond task\n" {
		t.Fatalf("stdout = %q, want alias-only tab-separated list", got)
	}
	if stderr.Len() != 0 {
		t.Fatalf("stderr = %q, want empty", stderr.String())
	}
}

func TestTaskCompletionItems_IncludesDescriptions(t *testing.T) {
	tasks := []TaskExport{
		{UUID: "uuid-1", Description: "First task"},
		{UUID: "", Description: "Ignored"},
		{UUID: "uuid-2", Description: "Second task"},
	}
	aliases := map[string]string{
		"uuid-1": "0",
		"uuid-2": "uuid-2", // same as UUID, so alias is skipped
	}

	got := taskCompletionItems(tasks, aliases)
	// Alias "0" differs from UUID so it gets its own entry; "uuid-2" matches
	// UUID so no alias entry is emitted for it.
	want := []string{
		"0\tFirst task",
		"uuid-1\tFirst task",
		"uuid-2\tSecond task",
	}
	if strings.Join(got, "\n") != strings.Join(want, "\n") {
		t.Fatalf("taskCompletionItems = %v, want %v", got, want)
	}
}

func TestTruncateDescription_ShortString(t *testing.T) {
	got := truncateDescription("hello", 10)
	if got != "hello" {
		t.Fatalf("truncateDescription = %q, want %q", got, "hello")
	}
}

func TestTruncateDescription_ExactLength(t *testing.T) {
	got := truncateDescription("hello", 5)
	if got != "hello" {
		t.Fatalf("truncateDescription = %q, want %q", got, "hello")
	}
}

func TestTruncateDescription_LongString(t *testing.T) {
	got := truncateDescription("hello world", 5)
	if got != "hello…" {
		t.Fatalf("truncateDescription = %q, want %q", got, "hello…")
	}
}

func TestTruncateDescription_Unicode(t *testing.T) {
	// Japanese characters are multi-byte but each is one rune, so maxLen=3
	// should cut at 3 runes.
	got := truncateDescription("日本語テスト", 3)
	if got != "日本語…" {
		t.Fatalf("truncateDescription = %q, want %q", got, "日本語…")
	}
}

func TestTruncateDescription_CollapsesNewlines(t *testing.T) {
	// Multi-line descriptions must collapse to a single line so the
	// tab-separated completion output is not broken into bogus entries.
	got := truncateDescription("first line\nsecond line\n\nfourth", 100)
	if got != "first line second line fourth" {
		t.Fatalf("truncateDescription = %q, want single-line collapse", got)
	}
	if strings.ContainsAny(got, "\r\n") {
		t.Fatalf("truncateDescription left a newline in %q", got)
	}
}

func TestOneLineDescription(t *testing.T) {
	cases := map[string]string{
		"plain":                   "plain",
		"a\nb":                    "a b",
		"a\r\nb":                  "a b",
		"a\n\n\nb":                "a b",
		"  leading\n  indented  ": "leading indented",
		"keep  internal  spaces":  "keep  internal  spaces",
	}
	for in, want := range cases {
		if got := oneLineDescription(in); got != want {
			t.Fatalf("oneLineDescription(%q) = %q, want %q", in, got, want)
		}
	}
}