diff options
| -rw-r--r-- | docs/fish-completion.md | 4 | ||||
| -rw-r--r-- | integrationtests/do_scope_test.go | 2 | ||||
| -rw-r--r-- | internal/askcli/command_complete_uuids.go | 32 | ||||
| -rw-r--r-- | internal/askcli/command_complete_uuids_test.go | 56 | ||||
| -rw-r--r-- | internal/askcli/commands_registry.go | 6 | ||||
| -rw-r--r-- | internal/askcli/completion.go | 4 | ||||
| -rw-r--r-- | internal/askcli/completion_test.go | 6 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 37 |
8 files changed, 138 insertions, 9 deletions
diff --git a/docs/fish-completion.md b/docs/fish-completion.md index f55e4cf..95de56a 100644 --- a/docs/fish-completion.md +++ b/docs/fish-completion.md @@ -3,8 +3,8 @@ The `do` task-management CLI embeds its Fish completion script in the binary and prints it with `do fish`. It completes the top-level `do` subcommands and the nested `do dep` operations. -It also completes task selectors for UUID-taking commands by reading pending tasks through `do 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. +It also completes task selectors for UUID-taking commands by reading pending tasks through `do complete-aliases`, which uses the local alias cache for stable short IDs. +The `do complete-uuids` command still emits both alias and UUID lines for scripts and tests that need the full selector list. Selector suggestions stop once a command has consumed its selector argument, and `do dep add` / `do dep rm` suggest selectors for both task positions. When typing `do add depends:...`, Fish also completes the comma-separated dependency selector list inside the `depends:` modifier. The script preserves the global `--json` flag. diff --git a/integrationtests/do_scope_test.go b/integrationtests/do_scope_test.go index f24f5ca..c87c67c 100644 --- a/integrationtests/do_scope_test.go +++ b/integrationtests/do_scope_test.go @@ -77,7 +77,7 @@ func hasTag(tags []string, want string) bool { // hasSelectorLine reports whether any line in output starts with want followed // by a tab or end-of-line. This handles the "selector\tdescription" format -// emitted by complete-uuids for fish shell autocompletion. +// emitted by complete-uuids / complete-aliases (tab-separated selector lines). func hasSelectorLine(output, want string) bool { for _, line := range strings.Split(strings.TrimSpace(output), "\n") { line = strings.TrimSpace(line) diff --git a/internal/askcli/command_complete_uuids.go b/internal/askcli/command_complete_uuids.go index 8c00a7b..bc160b6 100644 --- a/internal/askcli/command_complete_uuids.go +++ b/internal/askcli/command_complete_uuids.go @@ -13,6 +13,18 @@ import ( const completionDescriptionMaxLen = 60 func (d *Dispatcher) handleCompleteUUIDs(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { + return d.completeTaskSelectors(ctx, args, stdout, stderr, taskCompletionItems) +} + +func (d *Dispatcher) handleCompleteAliases(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { + return d.completeTaskSelectors(ctx, args, stdout, stderr, taskCompletionAliasItems) +} + +// taskCompletionLinesFn builds tab-separated "selector\tdescription" lines for +// completion output (full list vs alias-only for Fish). +type taskCompletionLinesFn func(tasks []TaskExport, aliases map[string]string) []string + +func (d *Dispatcher) completeTaskSelectors(ctx context.Context, args []string, stdout, stderr io.Writer, lines taskCompletionLinesFn) (int, error) { _ = args var outBuf bytes.Buffer code, err := d.runner.Run(ctx, []string{"status:pending", "export"}, nil, &outBuf, stderr) @@ -31,7 +43,7 @@ func (d *Dispatcher) handleCompleteUUIDs(ctx context.Context, args []string, std } // Each line is "selector\tdescription" so fish shell can show the task // summary alongside the ID in the autocompletion menu. - for _, item := range taskCompletionItems(tasks, aliases) { + for _, item := range lines(tasks, aliases) { _, _ = io.WriteString(stdout, item+"\n") } return 0, nil @@ -55,6 +67,24 @@ func taskCompletionItems(tasks []TaskExport, aliases map[string]string) []string return items } +// taskCompletionAliasItems returns tab-separated "selector\tdescription" lines +// only for short alias IDs (never the raw UUID). Used by Fish completion via +// `complete-aliases`; non-shell callers still use complete-uuids for full +// selector lists. +func taskCompletionAliasItems(tasks []TaskExport, aliases map[string]string) []string { + items := make([]string, 0, len(tasks)) + for _, task := range tasks { + if task.UUID == "" { + continue + } + desc := truncateDescription(task.Description, completionDescriptionMaxLen) + if alias := displayTaskAlias(task.UUID, aliases); alias != "" && alias != task.UUID { + items = append(items, alias+"\t"+desc) + } + } + return items +} + // truncateDescription shortens s to at most maxLen characters, appending "…" // when the string is cut, so the completion hint fits on one line. func truncateDescription(s string, maxLen int) string { diff --git a/internal/askcli/command_complete_uuids_test.go b/internal/askcli/command_complete_uuids_test.go index 92bf244..0859acc 100644 --- a/internal/askcli/command_complete_uuids_test.go +++ b/internal/askcli/command_complete_uuids_test.go @@ -146,6 +146,62 @@ func TestTaskCompletionSelectors_SkipsMissingAndDuplicateAliases(t *testing.T) { } } +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"}, diff --git a/internal/askcli/commands_registry.go b/internal/askcli/commands_registry.go index 4d8d9ba..1c2e60d 100644 --- a/internal/askcli/commands_registry.go +++ b/internal/askcli/commands_registry.go @@ -204,4 +204,10 @@ func init() { handler: wrapSimpleCommand((*Dispatcher).handleCompleteUUIDs), includeInCompletion: false, }) + commandRegistry.add(commandEntry{ + name: "complete-aliases", + description: "Emit short task selector list for Fish completion", + handler: wrapSimpleCommand((*Dispatcher).handleCompleteAliases), + includeInCompletion: false, + }) } diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index 3e30c6c..e7b837e 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -274,9 +274,9 @@ func writeFishTaskSelectorFunction(b *strings.Builder, binaryPath string) { b.WriteString(" end\n") b.WriteString(" set -l selectors\n") b.WriteString(" if test -n \"$scope_prefix\"\n") - b.WriteString(" set selectors (command $do_bin $scope_prefix complete-uuids 2>/dev/null)\n") + b.WriteString(" set selectors (command $do_bin $scope_prefix complete-aliases 2>/dev/null)\n") b.WriteString(" else\n") - b.WriteString(" set selectors (command $do_bin complete-uuids 2>/dev/null)\n") + b.WriteString(" set selectors (command $do_bin complete-aliases 2>/dev/null)\n") b.WriteString(" end\n") b.WriteString(" if test $status -ne 0\n") b.WriteString(" return 1\n") diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index 012927d..5e18c09 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -28,8 +28,8 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { "function __do_add_dependency_modifiers", `set -l do_bin "do"`, "set -l selectors", - "set selectors (command $do_bin complete-uuids 2>/dev/null)", - "set selectors (command $do_bin $scope_prefix complete-uuids 2>/dev/null)", + "set selectors (command $do_bin complete-aliases 2>/dev/null)", + "set selectors (command $do_bin $scope_prefix complete-aliases 2>/dev/null)", "complete -c do -n '__do_in_uuid_context' -a '(__do_task_selectors)' -d 'Task selector'", "complete -c do -n '__do_in_dep_uuid_context' -a '(__do_task_selectors)' -d 'Task selector'", "complete -c do -n '__do_in_add_dep_modifier_context' -a '(__do_add_dependency_modifiers)' -d 'Task dependency'", @@ -139,7 +139,7 @@ func TestFishCompletionFor_EmbedsBinaryPath(t *testing.T) { script := FishCompletionFor(`/tmp/do "$HOME"`) for _, line := range []string{ `set -l do_bin "/tmp/do \"\$HOME\""`, - "set selectors (command $do_bin complete-uuids 2>/dev/null)", + "set selectors (command $do_bin complete-aliases 2>/dev/null)", } { if !strings.Contains(script, line) { t.Fatalf("script missing %q", line) diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 90c1b99..0e13788 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -90,6 +90,38 @@ func TestDispatcher_CompleteUUIDsSubcommand(t *testing.T) { } } +func TestDispatcher_CompleteAliasesSubcommand(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, 27, 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 strings.Join(args, " ") != "status:pending export" { + t.Fatalf("args = %v, want pending export", args) + } + _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Sample task"}]`) + return 0, nil + }}) + var stdout, stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"complete-aliases"}, nil, &stdout, &stderr) + if err != nil { + t.Fatalf("complete-aliases returned error: %v", err) + } + if code != 0 { + t.Fatalf("complete-aliases code = %d, want 0", code) + } + // Fish completion uses aliases only; UUID lines are omitted. + if got := stdout.String(); got != "0\tSample task\n" { + t.Fatalf("stdout = %q, want alias-only tab-separated list", got) + } +} + func TestDispatcher_LongHelp(t *testing.T) { d := NewDispatcher(nil) var stdout bytes.Buffer @@ -323,6 +355,11 @@ func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) { wantCalls: [][]string{{"status:pending", "export"}}, }, { + name: "complete-aliases", + args: []string{"complete-aliases"}, + wantCalls: [][]string{{"status:pending", "export"}}, + }, + { name: "info", args: []string{"info", "test-uuid"}, wantCalls: [][]string{{"uuid:test-uuid", "export"}}, |
