diff options
| -rw-r--r-- | docs/fish-completion.md | 1 | ||||
| -rw-r--r-- | internal/askcli/command_dep_test.go | 10 | ||||
| -rw-r--r-- | internal/askcli/command_write_test.go | 78 | ||||
| -rw-r--r-- | internal/askcli/completion.go | 51 | ||||
| -rw-r--r-- | internal/askcli/completion_test.go | 53 |
5 files changed, 144 insertions, 49 deletions
diff --git a/docs/fish-completion.md b/docs/fish-completion.md index d7e7289..f51d362 100644 --- a/docs/fish-completion.md +++ b/docs/fish-completion.md @@ -5,6 +5,7 @@ The `ask` task-management CLI embeds its Fish completion script in the binary an It completes the top-level `ask` subcommands and the nested `ask dep` operations. It also completes task selectors for UUID-taking commands by reading pending tasks through `ask complete-uuids`, which uses the local alias cache for stable short IDs. Fish suggests each task's alias ID first and also keeps the raw UUID available as a fallback selector. +Selector suggestions stop once a command has consumed its selector argument, and `ask dep add` / `ask dep rm` suggest selectors for both task positions. The script preserves the global `--json` flag. Load it into the current Fish session: diff --git a/internal/askcli/command_dep_test.go b/internal/askcli/command_dep_test.go index 408ef86..f674d28 100644 --- a/internal/askcli/command_dep_test.go +++ b/internal/askcli/command_dep_test.go @@ -58,6 +58,16 @@ func TestHandleDep_AddSuccess(t *testing.T) { } func TestHandleDep_RmSuccess(t *testing.T) { + dir := t.TempDir() + oldRoot := taskAliasCacheRoot + oldNow := nowTaskAliasCache + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + defer func() { + taskAliasCacheRoot = oldRoot + nowTaskAliasCache = oldNow + }() + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { if len(args) == 2 && args[1] == "export" { switch args[0] { diff --git a/internal/askcli/command_write_test.go b/internal/askcli/command_write_test.go index 31ea25a..981df8a 100644 --- a/internal/askcli/command_write_test.go +++ b/internal/askcli/command_write_test.go @@ -10,21 +10,29 @@ import ( "time" ) -func TestHandleStart_AliasSelector(t *testing.T) { +func useIsolatedTaskAliasCache(t *testing.T) time.Time { + t.Helper() + dir := t.TempDir() oldRoot := taskAliasCacheRoot oldNow := nowTaskAliasCache + fixedNow := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } - nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } - defer func() { + nowTaskAliasCache = func() time.Time { return fixedNow } + t.Cleanup(func() { taskAliasCacheRoot = oldRoot nowTaskAliasCache = oldNow - }() + }) + return fixedNow +} + +func TestHandleStart_AliasSelector(t *testing.T) { + now := useIsolatedTaskAliasCache(t) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, }) @@ -49,20 +57,12 @@ func TestHandleStart_AliasSelector(t *testing.T) { } func TestHandleDenotate_Success(t *testing.T) { - dir := t.TempDir() - oldRoot := taskAliasCacheRoot - oldNow := nowTaskAliasCache - taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } - nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + now := useIsolatedTaskAliasCache(t) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, }) @@ -111,20 +111,12 @@ func TestHandleDenotate_MissingArgs(t *testing.T) { } func TestHandleModify_Success(t *testing.T) { - dir := t.TempDir() - oldRoot := taskAliasCacheRoot - oldNow := nowTaskAliasCache - taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } - nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + now := useIsolatedTaskAliasCache(t) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, }) @@ -158,20 +150,12 @@ func TestHandleModify_NumericID(t *testing.T) { } func TestHandleAnnotate_Success(t *testing.T) { - dir := t.TempDir() - oldRoot := taskAliasCacheRoot - oldNow := nowTaskAliasCache - taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } - nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + now := useIsolatedTaskAliasCache(t) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, }) @@ -205,20 +189,12 @@ func TestHandleAnnotate_MissingArgs(t *testing.T) { } func TestHandleStart_Success(t *testing.T) { - dir := t.TempDir() - oldRoot := taskAliasCacheRoot - oldNow := nowTaskAliasCache - taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } - nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + now := useIsolatedTaskAliasCache(t) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, }) @@ -252,6 +228,8 @@ func TestHandleStart_MissingUUID(t *testing.T) { } func TestHandleStop_Success(t *testing.T) { + useIsolatedTaskAliasCache(t) + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { if len(args) == 2 && args[0] == "uuid:test-uuid" && args[1] == "export" { io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`) @@ -267,6 +245,8 @@ func TestHandleStop_Success(t *testing.T) { } func TestHandleDone_Success(t *testing.T) { + useIsolatedTaskAliasCache(t) + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { if len(args) == 2 && args[0] == "uuid:test-uuid" && args[1] == "export" { io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`) @@ -282,6 +262,8 @@ func TestHandleDone_Success(t *testing.T) { } func TestHandlePriority_Success(t *testing.T) { + useIsolatedTaskAliasCache(t) + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { if len(args) == 2 && args[0] == "uuid:test-uuid" && args[1] == "export" { io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`) @@ -309,6 +291,8 @@ func TestHandlePriority_MissingArgs(t *testing.T) { } func TestHandleTag_Success(t *testing.T) { + useIsolatedTaskAliasCache(t) + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { if len(args) == 2 && args[0] == "uuid:test-uuid" && args[1] == "export" { io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`) @@ -355,6 +339,8 @@ func TestAllWriteHandlers_PassCorrectArgs(t *testing.T) { for _, tc := range testCases { t.Run(tc.subcommand, func(t *testing.T) { + useIsolatedTaskAliasCache(t) + var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { if len(args) == 2 && args[1] == "export" { @@ -399,6 +385,8 @@ func TestAllWriteHandlers_AcceptUUIDPrefix(t *testing.T) { for _, tc := range testCases { t.Run(tc.subcommand, func(t *testing.T) { + useIsolatedTaskAliasCache(t) + var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { if len(args) == 2 && args[1] == "export" { diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index ec7631e..04dbb25 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -9,6 +9,19 @@ type fishCompletionItem struct { description string } +var askSingleSelectorCompletionCommands = []string{ + "info", + "annotate", + "start", + "stop", + "done", + "priority", + "tag", + "modify", + "denotate", + "delete", +} + var askRootCompletionItems = []fishCompletionItem{ {name: "add", description: "Create a new task"}, {name: "list", description: "List active tasks"}, @@ -49,6 +62,34 @@ var askUUIDCompletionItems = []fishCompletionItem{ {name: "delete", description: "Delete a task"}, } +func fishSingleSelectorCompletionContext(positional []string) bool { + if len(positional) != 1 { + return false + } + + for _, command := range askSingleSelectorCompletionCommands { + if positional[0] == command { + return true + } + } + return false +} + +func fishDepSelectorCompletionContext(positional []string) bool { + if len(positional) < 2 || positional[0] != "dep" { + return false + } + + switch positional[1] { + case "add", "rm": + return len(positional) == 2 || len(positional) == 3 + case "list": + return len(positional) == 2 + default: + return false + } +} + func FishCompletion() string { return FishCompletionFor("ask") } @@ -136,11 +177,13 @@ func writeFishUUIDContextFunction(b *strings.Builder) { b.WriteString(" if test (count $positional) -eq 0\n") b.WriteString(" return 1\n") b.WriteString(" end\n") - b.WriteString(" if test (count $positional) -gt 2\n") + b.WriteString(" if test (count $positional) -ne 1\n") b.WriteString(" return 1\n") b.WriteString(" end\n") b.WriteString(" switch $positional[1]\n") - b.WriteString(" case info annotate start stop done priority tag modify denotate delete\n") + b.WriteString(" case ") + b.WriteString(strings.Join(askSingleSelectorCompletionCommands, " ")) + b.WriteString("\n") b.WriteString(" return 0\n") b.WriteString(" case '*'\n") b.WriteString(" return 1\n") @@ -167,11 +210,11 @@ func writeFishDepUUIDContextFunction(b *strings.Builder) { b.WriteString(" end\n") b.WriteString(" switch $positional[2]\n") b.WriteString(" case add rm\n") - b.WriteString(" if test (count $positional) -le 4\n") + b.WriteString(" if test (count $positional) -eq 2 -o (count $positional) -eq 3\n") b.WriteString(" return 0\n") b.WriteString(" end\n") b.WriteString(" case list\n") - b.WriteString(" if test (count $positional) -le 3\n") + b.WriteString(" if test (count $positional) -eq 2\n") b.WriteString(" return 0\n") b.WriteString(" end\n") b.WriteString(" case '*'\n") diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 87a3ca9..14afdf5 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -35,6 +35,59 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { } } +func TestFishSingleSelectorCompletionContext(t *testing.T) { + testCases := []struct { + name string + positional []string + want bool + }{ + {name: "info expects selector", positional: []string{"info"}, want: true}, + {name: "annotate expects selector", positional: []string{"annotate"}, want: true}, + {name: "priority expects selector", positional: []string{"priority"}, want: true}, + {name: "delete expects selector", positional: []string{"delete"}, want: true}, + {name: "annotate stops after selector", positional: []string{"annotate", "0"}, want: false}, + {name: "priority stops after selector", positional: []string{"priority", "0"}, want: false}, + {name: "modify stops after selector", positional: []string{"modify", "0"}, want: false}, + {name: "dep is not a single selector command", positional: []string{"dep"}, want: false}, + {name: "empty positional", positional: nil, want: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := fishSingleSelectorCompletionContext(tc.positional); got != tc.want { + t.Fatalf("fishSingleSelectorCompletionContext(%v) = %t, want %t", tc.positional, got, tc.want) + } + }) + } +} + +func TestFishDepSelectorCompletionContext(t *testing.T) { + testCases := []struct { + name string + positional []string + want bool + }{ + {name: "dep add first selector", positional: []string{"dep", "add"}, want: true}, + {name: "dep add second selector", positional: []string{"dep", "add", "0"}, want: true}, + {name: "dep add stops after second selector", positional: []string{"dep", "add", "0", "1"}, want: false}, + {name: "dep rm first selector", positional: []string{"dep", "rm"}, want: true}, + {name: "dep rm second selector", positional: []string{"dep", "rm", "0"}, want: true}, + {name: "dep rm stops after second selector", positional: []string{"dep", "rm", "0", "1"}, want: false}, + {name: "dep list selector", positional: []string{"dep", "list"}, want: true}, + {name: "dep list stops after selector", positional: []string{"dep", "list", "0"}, want: false}, + {name: "dep unknown operation", positional: []string{"dep", "noop"}, want: false}, + {name: "non dep command", positional: []string{"info"}, want: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := fishDepSelectorCompletionContext(tc.positional); got != tc.want { + t.Fatalf("fishDepSelectorCompletionContext(%v) = %t, want %t", tc.positional, got, tc.want) + } + }) + } +} + func TestFishCompletionFor_EmbedsBinaryPath(t *testing.T) { script := FishCompletionFor(`/tmp/ask "$HOME"`) for _, line := range []string{ |
