package cmd import ( "bytes" "os" "path/filepath" "sort" "strings" "testing" "time" "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/workspace" ) // completionTestEnv mirrors listTestEnv but is duplicated here so the // completion tests are self-contained and can vary the fixtures. func completionTestEnv(t *testing.T) string { t.Helper() root := t.TempDir() mk := func(category, dirName, status, taskType string) { dir := filepath.Join(root, category, dirName) os.MkdirAll(dir, 0755) now := time.Now().UTC().Truncate(time.Second) slug := dirName[11:] meta := &workspace.TaskMeta{ ID: "t", Slug: slug, Title: slug, CreatedAt: now, UpdatedAt: now, Status: status, Category: category, Type: taskType, Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if status == "archived" { meta.ArchivedAt = &now } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) } mk("general", "2026-04-05_alpha-active", "active", "task") mk("general", "2026-04-04_beta-archived", "archived", "task") mk("projects", "2026-04-03_gamma-active", "active", "project") mk("projects", "2026-04-02_delta-archived", "archived", "project") return root } // callCompletion invokes a ValidArgsFunction directly under a CTASK_ROOT // override, returning the candidate list (sorted for stable comparisons) // and the shell directive. func callCompletion(t *testing.T, root string, fn func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective)) ([]string, cobra.ShellCompDirective) { t.Helper() prev := os.Getenv("CTASK_ROOT") os.Setenv("CTASK_ROOT", root) defer func() { if prev == "" { os.Unsetenv("CTASK_ROOT") } else { os.Setenv("CTASK_ROOT", prev) } }() candidates, dir := fn(nil, nil, "") sort.Strings(candidates) return candidates, dir } func TestCompleteWorkspacesActiveOnly(t *testing.T) { root := completionTestEnv(t) got, _ := callCompletion(t, root, completeWorkspaces(completionActive)) want := []string{ "2026-04-03_gamma-active", "2026-04-05_alpha-active", } if !equalStringSlices(got, want) { t.Errorf("active filter:\nwant %v\ngot %v", want, got) } } func TestCompleteWorkspacesArchivedOnly(t *testing.T) { root := completionTestEnv(t) got, _ := callCompletion(t, root, completeWorkspaces(completionArchived)) want := []string{ "2026-04-02_delta-archived", "2026-04-04_beta-archived", } if !equalStringSlices(got, want) { t.Errorf("archived filter:\nwant %v\ngot %v", want, got) } } func TestCompleteWorkspacesAny(t *testing.T) { root := completionTestEnv(t) got, _ := callCompletion(t, root, completeWorkspaces(completionAny)) want := []string{ "2026-04-02_delta-archived", "2026-04-03_gamma-active", "2026-04-04_beta-archived", "2026-04-05_alpha-active", } if !equalStringSlices(got, want) { t.Errorf("any filter:\nwant %v\ngot %v", want, got) } } func TestCompleteWorkspacesSecondArgReturnsNoCompletion(t *testing.T) { // Once the user has typed the first positional arg, completion of further // args must not enumerate workspaces. root := completionTestEnv(t) prev := os.Getenv("CTASK_ROOT") os.Setenv("CTASK_ROOT", root) defer func() { if prev == "" { os.Unsetenv("CTASK_ROOT") } else { os.Setenv("CTASK_ROOT", prev) } }() candidates, dir := completeWorkspaces(completionAny)(nil, []string{"already-typed"}, "") if len(candidates) != 0 { t.Errorf("expected no candidates after first positional arg, got: %v", candidates) } if dir != cobra.ShellCompDirectiveNoFileComp { t.Errorf("expected ShellCompDirectiveNoFileComp, got: %v", dir) } } // genCompletion calls the shell-specific generator that Cobra's auto-injected // `ctask completion ` subcommand uses internally. Bypasses rootCmd // argument-routing state so the tests are independent. func genCompletion(shell string) (string, error) { var buf bytes.Buffer var err error switch shell { case "bash": err = rootCmd.GenBashCompletionV2(&buf, true) case "zsh": err = rootCmd.GenZshCompletion(&buf) case "fish": err = rootCmd.GenFishCompletion(&buf, true) case "powershell": err = rootCmd.GenPowerShellCompletionWithDesc(&buf) } return buf.String(), err } func TestCompletionBashGenerates(t *testing.T) { out, err := genCompletion("bash") if err != nil { t.Fatalf("GenBashCompletionV2: %v", err) } if len(out) == 0 { t.Fatal("expected non-empty bash completion script") } if !strings.Contains(out, "ctask") { t.Errorf("bash completion script should mention 'ctask':\n%s", truncate(out, 200)) } } func TestCompletionPowerShellGenerates(t *testing.T) { out, err := genCompletion("powershell") if err != nil { t.Fatalf("GenPowerShellCompletionWithDesc: %v", err) } if len(out) == 0 { t.Fatal("expected non-empty powershell completion script") } if !strings.Contains(out, "ctask") { t.Errorf("powershell completion script should mention 'ctask':\n%s", truncate(out, 200)) } } func TestCompletionZshGenerates(t *testing.T) { out, err := genCompletion("zsh") if err != nil { t.Fatalf("GenZshCompletion: %v", err) } if len(out) == 0 { t.Fatal("expected non-empty zsh completion script") } } func TestCompletionFishGenerates(t *testing.T) { out, err := genCompletion("fish") if err != nil { t.Fatalf("GenFishCompletion: %v", err) } if len(out) == 0 { t.Fatal("expected non-empty fish completion script") } } // TestCompletionSubcommandViaExecute verifies that the user-facing path // `ctask completion bash` actually runs end-to-end through Cobra. Cobra // adds the `completion` subcommand lazily on first Execute(), so a Find() // before any Execute() returns "unknown command" — exercising the real // path is the right test. func TestCompletionSubcommandViaExecute(t *testing.T) { var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) defer rootCmd.SetOut(os.Stdout) defer rootCmd.SetErr(os.Stderr) rootCmd.SetArgs([]string{"completion", "bash"}) defer rootCmd.SetArgs(nil) if err := rootCmd.Execute(); err != nil { t.Fatalf("rootCmd.Execute(\"completion\", \"bash\"): %v", err) } if buf.Len() == 0 { t.Fatal("expected non-empty bash completion via Execute") } if !strings.Contains(buf.String(), "ctask") { t.Errorf("end-to-end bash completion should mention 'ctask'") } } func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] }