From a80ad2b4691d0df63f68c6977ee18444e7bb752f Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 18 Jun 2026 08:05:32 +0300 Subject: ik0 replace remaining test seams with DI --- cmd/hexai-mcp-server/main.go | 33 ++++++--- cmd/hexai-mcp-server/main_test.go | 54 ++++++--------- internal/askcli/command_add.go | 2 +- internal/askcli/command_complete_uuids.go | 2 +- internal/askcli/command_complete_uuids_test.go | 31 +++------ internal/askcli/command_delete_test.go | 31 +++------ internal/askcli/command_dep.go | 2 +- internal/askcli/command_dep_test.go | 65 ++++++------------ internal/askcli/command_info.go | 2 +- internal/askcli/command_info_add_test.go | 94 ++++++++------------------ internal/askcli/command_list.go | 2 +- internal/askcli/command_list_test.go | 80 ++++++++-------------- internal/askcli/command_projects.go | 23 +++++-- internal/askcli/command_projects_test.go | 52 ++++---------- internal/askcli/command_start_deps.go | 2 +- internal/askcli/command_start_deps_test.go | 37 +++------- internal/askcli/command_urgency.go | 2 +- internal/askcli/command_urgency_test.go | 18 ++--- internal/askcli/command_watch_test.go | 19 +----- internal/askcli/command_write_test.go | 10 +-- internal/askcli/dispatch.go | 20 ++++-- internal/askcli/dispatch_test.go | 38 ++--------- internal/askcli/render_task_list.go | 8 ++- internal/askcli/render_task_list_test.go | 21 ++---- internal/askcli/task_alias_cache.go | 54 ++++++++++++--- internal/askcli/task_alias_cache_test.go | 84 ++++++++--------------- internal/askcli/task_selector.go | 15 ++-- internal/askcli/task_selector_test.go | 87 ++++++++++++------------ internal/hexaiaction/tui_custom.go | 2 +- internal/hexaiaction/tui_custom_test.go | 17 +++++ internal/hexaicli/cache_test.go | 30 ++++---- internal/hexaicli/run.go | 27 ++++++-- internal/hexaicli/run_model_override_test.go | 7 +- internal/hexaicli/run_output_test.go | 6 +- internal/hexaicli/run_test.go | 13 ++-- internal/hexaicli/run_timeout_test.go | 8 +-- internal/hexaicli/runner.go | 3 + internal/hexaicli/simulation_test.go | 12 ++-- internal/tmuxedit/agentutil.go | 12 +++- internal/tmuxedit/agentutil_test.go | 7 +- internal/tmuxedit/pane.go | 15 ++-- internal/tmuxedit/send_test.go | 2 +- 42 files changed, 452 insertions(+), 597 deletions(-) diff --git a/cmd/hexai-mcp-server/main.go b/cmd/hexai-mcp-server/main.go index 03150bb..741e310 100644 --- a/cmd/hexai-mcp-server/main.go +++ b/cmd/hexai-mcp-server/main.go @@ -25,13 +25,6 @@ func buildOverrides(opts mcpOptions) hexaimcp.MCPOverrides { } } -// Seams for testing: override in tests to avoid launching real MCP server. -// Signatures match hexaimcp.Run and hexaimcp.RunBackfill respectively. -var ( - runMCP = hexaimcp.Run - runBackfill = hexaimcp.RunBackfill -) - // deprecationWarning is the notice runMain emits on every startup so users // see this binary is experimental. Kept as a constant (not printf'd) so // tests can assert on its contents directly. @@ -65,6 +58,18 @@ type mcpOptions struct { showVersion bool } +type mcpDeps struct { + runMCP func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error + runBackfill func(context.Context, string, string, hexaimcp.MCPOverrides) error +} + +func defaultMCPDeps() mcpDeps { + return mcpDeps{ + runMCP: hexaimcp.Run, + runBackfill: hexaimcp.RunBackfill, + } +} + func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } // runMain prints the deprecation warning, parses flags, and delegates to @@ -73,6 +78,10 @@ func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } // Pulling this out of main keeps it testable without touching package-level // flag state. func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + return runMainWithDeps(args, stdin, stdout, stderr, defaultMCPDeps()) +} + +func runMainWithDeps(args []string, stdin io.Reader, stdout, stderr io.Writer, deps mcpDeps) int { fmt.Fprint(stderr, deprecationWarning) defaultLog, err := defaultLogPath() @@ -107,7 +116,7 @@ func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := run(ctx, opts, stdin, stdout, stderr); err != nil { + if err := runWithDeps(ctx, opts, stdin, stdout, stderr, deps); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } @@ -118,6 +127,10 @@ func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { // CLI flag values are passed via MCPOverrides instead of environment variables. // ctx is threaded into the server/backfill so they stop on shutdown signals. func run(ctx context.Context, opts mcpOptions, stdin io.Reader, stdout, stderr io.Writer) error { + return runWithDeps(ctx, opts, stdin, stdout, stderr, defaultMCPDeps()) +} + +func runWithDeps(ctx context.Context, opts mcpOptions, stdin io.Reader, stdout, stderr io.Writer, deps mcpDeps) error { if opts.showVersion { fmt.Fprintln(stdout, internal.Version) return nil @@ -127,10 +140,10 @@ func run(ctx context.Context, opts mcpOptions, stdin io.Reader, stdout, stderr i // Handle backfill operation if opts.syncAll { - return runBackfill(ctx, opts.logPath, opts.configPath, overrides) + return deps.runBackfill(ctx, opts.logPath, opts.configPath, overrides) } - return runMCP(ctx, opts.logPath, opts.configPath, overrides, stdin, stdout, stderr) + return deps.runMCP(ctx, opts.logPath, opts.configPath, overrides, stdin, stdout, stderr) } // defaultLogPath returns the default MCP log file path in the state directory. diff --git a/cmd/hexai-mcp-server/main_test.go b/cmd/hexai-mcp-server/main_test.go index b2a3895..ded0be7 100644 --- a/cmd/hexai-mcp-server/main_test.go +++ b/cmd/hexai-mcp-server/main_test.go @@ -66,12 +66,10 @@ func TestBuildOverrides(t *testing.T) { } func TestRun_SyncAll(t *testing.T) { - old := runBackfill - t.Cleanup(func() { runBackfill = old }) - var gotLog, gotConfig string var gotOverrides hexaimcp.MCPOverrides - runBackfill = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides) error { + deps := defaultMCPDeps() + deps.runBackfill = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides) error { gotLog = logPath gotConfig = configPath gotOverrides = overrides @@ -86,7 +84,7 @@ func TestRun_SyncAll(t *testing.T) { slashCommandSync: true, slashCommandDir: "/tmp/cmds", } - if err := run(context.Background(), opts, nil, nil, nil); err != nil { + if err := runWithDeps(context.Background(), opts, nil, nil, nil, deps); err != nil { t.Fatalf("run syncAll: %v", err) } if gotLog != "/tmp/test.log" { @@ -107,30 +105,26 @@ func TestRun_SyncAll(t *testing.T) { } func TestRun_SyncAllError(t *testing.T) { - old := runBackfill - t.Cleanup(func() { runBackfill = old }) - wantErr := errors.New("backfill failed") - runBackfill = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides) error { return wantErr } + deps := defaultMCPDeps() + deps.runBackfill = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides) error { return wantErr } opts := mcpOptions{syncAll: true} - if err := run(context.Background(), opts, nil, nil, nil); !errors.Is(err, wantErr) { + if err := runWithDeps(context.Background(), opts, nil, nil, nil, deps); !errors.Is(err, wantErr) { t.Fatalf("expected backfill error, got: %v", err) } } func TestRun_MCPServer(t *testing.T) { - old := runMCP - t.Cleanup(func() { runMCP = old }) - called := false - runMCP = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides, stdin io.Reader, stdout, stderr io.Writer) error { + deps := defaultMCPDeps() + deps.runMCP = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides, stdin io.Reader, stdout, stderr io.Writer) error { called = true return nil } opts := mcpOptions{logPath: "/tmp/mcp.log"} - if err := run(context.Background(), opts, nil, nil, nil); err != nil { + if err := runWithDeps(context.Background(), opts, nil, nil, nil, deps); err != nil { t.Fatalf("run MCP: %v", err) } if !called { @@ -139,15 +133,13 @@ func TestRun_MCPServer(t *testing.T) { } func TestRun_MCPServerError(t *testing.T) { - old := runMCP - t.Cleanup(func() { runMCP = old }) - wantErr := errors.New("server failed") - runMCP = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides, _ io.Reader, _, _ io.Writer) error { + deps := defaultMCPDeps() + deps.runMCP = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides, _ io.Reader, _, _ io.Writer) error { return wantErr } - if err := run(context.Background(), mcpOptions{}, nil, nil, nil); !errors.Is(err, wantErr) { + if err := runWithDeps(context.Background(), mcpOptions{}, nil, nil, nil, deps); !errors.Is(err, wantErr) { t.Fatalf("expected server error, got: %v", err) } } @@ -172,17 +164,15 @@ func TestRunMain_VersionFlag(t *testing.T) { // runMain --sync-all path: forwards parsed options to runBackfill and // returns 0 on success. func TestRunMain_SyncAllSuccess(t *testing.T) { - old := runBackfill - t.Cleanup(func() { runBackfill = old }) - var gotLog string - runBackfill = func(_ context.Context, logPath string, _ string, _ hexaimcp.MCPOverrides) error { + deps := defaultMCPDeps() + deps.runBackfill = func(_ context.Context, logPath string, _ string, _ hexaimcp.MCPOverrides) error { gotLog = logPath return nil } var stdout, stderr bytes.Buffer - code := runMain([]string{"-sync-all", "-log", "/tmp/sync.log"}, nil, &stdout, &stderr) + code := runMainWithDeps([]string{"-sync-all", "-log", "/tmp/sync.log"}, nil, &stdout, &stderr, deps) if code != 0 { t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -194,14 +184,13 @@ func TestRunMain_SyncAllSuccess(t *testing.T) { // runMain run-error path: when the underlying server fails, runMain must // return 1 (the production exit code) and write the error to stderr. func TestRunMain_ServerErrorReturnsOne(t *testing.T) { - old := runMCP - t.Cleanup(func() { runMCP = old }) - runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error { + deps := defaultMCPDeps() + deps.runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error { return errors.New("mcp boom") } var stdout, stderr bytes.Buffer - code := runMain(nil, nil, &stdout, &stderr) + code := runMainWithDeps(nil, nil, &stdout, &stderr, deps) if code != 1 { t.Fatalf("runMain code = %d, want 1", code) } @@ -212,15 +201,14 @@ func TestRunMain_ServerErrorReturnsOne(t *testing.T) { // Bad flag must yield exit 2 without ever invoking the server stub. func TestRunMain_BadFlagReturnsTwo(t *testing.T) { - old := runMCP - t.Cleanup(func() { runMCP = old }) called := false - runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error { + deps := defaultMCPDeps() + deps.runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error { called = true return nil } var stdout, stderr bytes.Buffer - code := runMain([]string{"--bogus"}, nil, &stdout, &stderr) + code := runMainWithDeps([]string{"--bogus"}, nil, &stdout, &stderr, deps) if code != 2 { t.Fatalf("runMain code = %d, want 2", code) } diff --git a/internal/askcli/command_add.go b/internal/askcli/command_add.go index ccfb034..0793693 100644 --- a/internal/askcli/command_add.go +++ b/internal/askcli/command_add.go @@ -56,7 +56,7 @@ func (d *Dispatcher) createTask(ctx context.Context, modifiers []string, descrip // problem as a warning on stderr but still report success on stdout with // exit 0 so the user does not retry and create a duplicate task. The // displayed identifier falls back to the UUID when no alias is available. - aliases, aliasErr := ensureTaskAliasesForUUIDs([]string{uuid}) + aliases, aliasErr := d.aliasCache.withDefaults().ensureTaskAliasesForUUIDs([]string{uuid}) if aliasErr != nil { fmt.Fprintf(stderr, "warning: failed to assign task alias: %v\n", aliasErr) aliases = nil diff --git a/internal/askcli/command_complete_uuids.go b/internal/askcli/command_complete_uuids.go index 5c26649..567602d 100644 --- a/internal/askcli/command_complete_uuids.go +++ b/internal/askcli/command_complete_uuids.go @@ -36,7 +36,7 @@ func (d *Dispatcher) completeTaskSelectors(ctx context.Context, args []string, s fmt.Fprintf(stderr, "error: failed to parse task data: %v\n", err) return 1, nil } - aliases, err := ensureTaskAliases(tasks) + aliases, err := d.aliasCache.withDefaults().ensureTaskAliases(tasks) if err != nil { fmt.Fprintf(stderr, "warning: failed to update task alias cache: %v\n", err) aliases = nil diff --git a/internal/askcli/command_complete_uuids_test.go b/internal/askcli/command_complete_uuids_test.go index 2c1b5fd..b7a2e80 100644 --- a/internal/askcli/command_complete_uuids_test.go +++ b/internal/askcli/command_complete_uuids_test.go @@ -13,14 +13,8 @@ import ( 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 - }() + now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { want := []string{"status:pending", "export"} @@ -30,6 +24,7 @@ func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) { _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"First task"},{"uuid":"uuid-2","description":"Second task"},{"uuid":""}]`) return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, err := d.handleCompleteUUIDs(context.Background(), nil, &stdout, &stderr) @@ -48,7 +43,7 @@ func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) { t.Fatalf("stderr = %q, want empty", stderr.String()) } - path, err := taskAliasCachePath() + path, err := deps.taskAliasCachePath() if err != nil { t.Fatalf("taskAliasCachePath: %v", err) } @@ -82,11 +77,9 @@ func TestHandleCompleteUUIDs_ParseError(t *testing.T) { 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 }() + deps := testTaskAliasCacheDeps(dir, nil) - path, err := taskAliasCachePath() + path, err := deps.taskAliasCachePath() if err != nil { t.Fatalf("taskAliasCachePath: %v", err) } @@ -104,6 +97,7 @@ func TestHandleCompleteUUIDs_RecoverFromCorruptAliasCache(t *testing.T) { _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Fallback task"}]`) return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, err := d.handleCompleteUUIDs(context.Background(), nil, &stdout, &stderr) @@ -168,14 +162,8 @@ func TestTaskCompletionAliasItems_OnlyShortAliases(t *testing.T) { 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 - }() + now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { want := []string{"status:pending", "export"} @@ -185,6 +173,7 @@ func TestHandleCompleteAliases_PrintsAliasesOnly(t *testing.T) { _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"First task"},{"uuid":"uuid-2","description":"Second task"},{"uuid":""}]`) return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, err := d.handleCompleteAliases(context.Background(), nil, &stdout, &stderr) diff --git a/internal/askcli/command_delete_test.go b/internal/askcli/command_delete_test.go index 62d3e48..d86ee0b 100644 --- a/internal/askcli/command_delete_test.go +++ b/internal/askcli/command_delete_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "path/filepath" "strings" "testing" "time" @@ -12,21 +11,15 @@ import ( func TestHandleDelete_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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid-123", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid-123", Alias: "0", CreatedAt: now}, }, - }) + }, deps) 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-123" && args[1] == "export" { @@ -35,6 +28,7 @@ func TestHandleDelete_Success(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, err := d.Dispatch(context.Background(), []string{"delete", "test-uuid-123"}, &bytes.Buffer{}, &stdout, &stderr) if code != 0 { @@ -120,21 +114,15 @@ func TestHandleDelete_PassesCorrectArgs(t *testing.T) { func TestHandleDelete_AliasSelector(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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid-123", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid-123", Alias: "0", CreatedAt: now}, }, - }) + }, deps) var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -145,6 +133,7 @@ func TestHandleDelete_AliasSelector(t *testing.T) { capturedArgs = args return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"delete", "0"}, &bytes.Buffer{}, &stdout, &stderr) diff --git a/internal/askcli/command_dep.go b/internal/askcli/command_dep.go index aa28df8..ce65aa4 100644 --- a/internal/askcli/command_dep.go +++ b/internal/askcli/command_dep.go @@ -75,7 +75,7 @@ func (d *Dispatcher) handleDepList(ctx context.Context, args []string, stdout, s if len(task.Depends) == 0 { _, _ = io.WriteString(stdout, "no dependencies\n") } else { - aliases, err := ensureTaskAliasesForUUIDs(task.Depends) + aliases, err := d.aliasCache.withDefaults().ensureTaskAliasesForUUIDs(task.Depends) if err != nil { fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err) return 1, nil diff --git a/internal/askcli/command_dep_test.go b/internal/askcli/command_dep_test.go index 4cbcd26..fe3ab70 100644 --- a/internal/askcli/command_dep_test.go +++ b/internal/askcli/command_dep_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "path/filepath" "strings" "testing" "time" @@ -12,22 +11,16 @@ import ( func TestHandleDep_AddSuccess(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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()}, - {UUID: "uuid-2", Alias: "1", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-1", Alias: "0", CreatedAt: now}, + {UUID: "uuid-2", Alias: "1", CreatedAt: now}, }, - }) + }, deps) var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -43,6 +36,7 @@ func TestHandleDep_AddSuccess(t *testing.T) { capturedArgs = args return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"dep", "add", "uuid-1", "uuid-2"}, nil, &stdout, &stderr) if code != 0 { @@ -59,14 +53,8 @@ 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 - }() + now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) 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" { @@ -80,6 +68,7 @@ func TestHandleDep_RmSuccess(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"dep", "rm", "uuid-1", "uuid-2"}, nil, &stdout, &stderr) if code != 0 { @@ -89,29 +78,24 @@ func TestHandleDep_RmSuccess(t *testing.T) { func TestHandleDep_ListSuccess(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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 3, Entries: []taskAliasCacheEntry{ - {UUID: "dep-1", Alias: "1", CreatedAt: nowTaskAliasCache()}, - {UUID: "dep-2", Alias: "2", CreatedAt: nowTaskAliasCache()}, - {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "dep-1", Alias: "1", CreatedAt: now}, + {UUID: "dep-2", Alias: "2", CreatedAt: now}, + {UUID: "uuid-1", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"uuid-1","description":"Task","status":"pending","priority":"M","tags":[],"urgency":10,"depends":["dep-1","dep-2"]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { _, _ = io.WriteString(stdout, jsonData) return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"dep", "list", "uuid-1"}, nil, &stdout, &stderr) if code != 0 { @@ -198,22 +182,16 @@ func TestHandleDep_AcceptUUIDPrefix(t *testing.T) { func TestHandleDep_AliasSelectors(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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()}, - {UUID: "uuid-2", Alias: "1", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-1", Alias: "0", CreatedAt: now}, + {UUID: "uuid-2", Alias: "1", CreatedAt: now}, }, - }) + }, deps) var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -229,6 +207,7 @@ func TestHandleDep_AliasSelectors(t *testing.T) { capturedArgs = args return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"dep", "add", "0", "1"}, nil, &stdout, &stderr) diff --git a/internal/askcli/command_info.go b/internal/askcli/command_info.go index ba2e37d..6c02583 100644 --- a/internal/askcli/command_info.go +++ b/internal/askcli/command_info.go @@ -16,7 +16,7 @@ func (d *Dispatcher) handleInfo(ctx context.Context, args []string, stdout, stde return code, nil } allUUIDs := append([]string{tasks[0].UUID}, tasks[0].Depends...) - aliases, err := ensureTaskAliasesForUUIDs(allUUIDs) + aliases, err := d.aliasCache.withDefaults().ensureTaskAliasesForUUIDs(allUUIDs) if err != nil { fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err) return 1, nil diff --git a/internal/askcli/command_info_add_test.go b/internal/askcli/command_info_add_test.go index b918518..ad82606 100644 --- a/internal/askcli/command_info_add_test.go +++ b/internal/askcli/command_info_add_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "io" - "path/filepath" "strings" "testing" "time" @@ -14,22 +13,16 @@ import ( func TestHandleInfo_Success(t *testing.T) { unsetTestEnv(t, "HEXAI_DEBUG") 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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, Entries: []taskAliasCacheEntry{ - {UUID: "dep-1", Alias: "1", CreatedAt: nowTaskAliasCache()}, - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "dep-1", Alias: "1", CreatedAt: now}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"test-uuid","description":"Test task","status":"pending","priority":"H","tags":["cli","agent"],"urgency":15.0,"depends":["dep-1"],"annotations":[{"description":"Note 1","entry":"2026-03-22T10:00:00Z"}]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -39,6 +32,7 @@ func TestHandleInfo_Success(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"info", "test-uuid"}, nil, &stdout, &stderr) if code != 0 { @@ -68,21 +62,15 @@ func TestHandleInfo_Success(t *testing.T) { func TestHandleInfo_Success_DebugShowsUUID(t *testing.T) { t.Setenv("HEXAI_DEBUG", "true") 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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"test-uuid","description":"Test task","status":"pending","priority":"H","tags":["cli","agent"],"urgency":15.0,"depends":[],"annotations":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -91,6 +79,7 @@ func TestHandleInfo_Success_DebugShowsUUID(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"info", "test-uuid"}, nil, &stdout, &stderr) @@ -107,21 +96,15 @@ func TestHandleInfo_Success_DebugShowsUUID(t *testing.T) { func TestHandleInfo_AssignsDependencyAliasesFromInfo(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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"test-uuid","description":"Test task","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":["dep-b","dep-a"]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -130,6 +113,7 @@ func TestHandleInfo_AssignsDependencyAliasesFromInfo(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"info", "test-uuid"}, nil, &stdout, &stderr) @@ -149,21 +133,15 @@ func TestHandleInfo_AssignsDependencyAliasesFromInfo(t *testing.T) { func TestHandleInfo_AliasSelector(t *testing.T) { unsetTestEnv(t, "HEXAI_DEBUG") 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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"test-uuid","description":"Test task","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -172,6 +150,7 @@ func TestHandleInfo_AliasSelector(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"info", "0"}, nil, &stdout, &stderr) @@ -188,21 +167,15 @@ func TestHandleInfo_AliasSelector(t *testing.T) { func TestHandleInfo_JSONIncludesAliasID(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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "test-uuid", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"test-uuid","description":"Test task","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -211,6 +184,7 @@ func TestHandleInfo_JSONIncludesAliasID(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"--json", "info", "0"}, nil, &stdout, &stderr) @@ -247,14 +221,8 @@ func TestHandleInfo_NumericID(t *testing.T) { func TestHandleInfo_MissingUUID(t *testing.T) { unsetTestEnv(t, "HEXAI_DEBUG") 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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) jsonData := `[{"uuid":"started-uuid","description":"Started task","status":"pending","priority":"M","start":"2026-03-26T10:00:00Z","urgency":5.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -263,6 +231,7 @@ func TestHandleInfo_MissingUUID(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"info"}, nil, &stdout, &stderr) if code != 0 { @@ -349,14 +318,11 @@ func TestHandleAdd_Success(t *testing.T) { // This prevents the previous bug where the user saw exit 1 and retried, // creating duplicate tasks. func TestHandleAdd_AliasAssignmentFailure(t *testing.T) { - oldRoot := taskAliasCacheRoot - taskAliasCacheRoot = func() (string, error) { return "", io.ErrUnexpectedEOF } - defer func() { taskAliasCacheRoot = oldRoot }() - d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { _, _ = io.WriteString(stdout, "Created task abc-123-def.") return 0, nil }}) + d.aliasCache = taskAliasCacheDeps{cacheRoot: func() (string, error) { return "", io.ErrUnexpectedEOF }} var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"add", "New task description"}, nil, &stdout, &stderr) diff --git a/internal/askcli/command_list.go b/internal/askcli/command_list.go index ebe26da..c1f37bb 100644 --- a/internal/askcli/command_list.go +++ b/internal/askcli/command_list.go @@ -55,7 +55,7 @@ func (d *Dispatcher) handleListWithFilters(ctx context.Context, initialFilters, } return tasks[i].Urgency > tasks[j].Urgency }) - return renderTaskList(tasks, stdout, stderr, d.jsonOutput) + return renderTaskListWithAliasLoader(tasks, stdout, stderr, d.jsonOutput, d.aliasCache.withDefaults().ensureTaskAliases) } func priorityOrder(p string) int { diff --git a/internal/askcli/command_list_test.go b/internal/askcli/command_list_test.go index c7c7b8a..82efe77 100644 --- a/internal/askcli/command_list_test.go +++ b/internal/askcli/command_list_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "path/filepath" "strings" "testing" "time" @@ -12,22 +11,16 @@ import ( func TestHandleList_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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()}, - {UUID: "uuid-2", Alias: "1", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-1", Alias: "0", CreatedAt: now}, + {UUID: "uuid-2", Alias: "1", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"H","tags":["cli"],"start":"2026-03-26T10:00:00Z","urgency":15.0,"depends":[]},{"uuid":"uuid-2","description":"Task 2","status":"completed","priority":"M","tags":["agent"],"urgency":10.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -39,6 +32,7 @@ func TestHandleList_Success(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"list"}, nil, &stdout, &stderr) if code != 0 { @@ -58,22 +52,16 @@ func TestHandleList_Success(t *testing.T) { func TestHandleList_SortedByPriority(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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()}, - {UUID: "uuid-2", Alias: "1", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-1", Alias: "0", CreatedAt: now}, + {UUID: "uuid-2", Alias: "1", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"uuid-2","description":"Task 2","status":"pending","priority":"M","tags":[],"urgency":10.0,"depends":[]},{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"H","tags":[],"urgency":5.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -85,6 +73,7 @@ func TestHandleList_SortedByPriority(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout bytes.Buffer d.Dispatch(context.Background(), []string{"list"}, nil, &stdout, &bytes.Buffer{}) output := stdout.String() @@ -114,21 +103,15 @@ func TestHandleList_EmptyList(t *testing.T) { func TestHandleAll_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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-1", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"uuid-1","description":"Done task","status":"completed","priority":"M","tags":[],"urgency":0.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -140,6 +123,7 @@ func TestHandleAll_Success(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"all"}, nil, &stdout, &stderr) if code != 0 { @@ -152,21 +136,15 @@ func TestHandleAll_Success(t *testing.T) { func TestHandleReady_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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-ready", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-ready", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"uuid-ready","description":"Ready task","status":"pending","priority":"H","tags":["READY"],"urgency":20.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -178,6 +156,7 @@ func TestHandleReady_Success(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"ready"}, nil, &stdout, &stderr) if code != 0 { @@ -190,21 +169,15 @@ func TestHandleReady_Success(t *testing.T) { func TestHandleCompleted_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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 1, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-done", Alias: "0", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-done", Alias: "0", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"uuid-done","description":"Done task","status":"completed","priority":"M","tags":[],"urgency":0.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -216,6 +189,7 @@ func TestHandleCompleted_Success(t *testing.T) { } return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"completed"}, nil, &stdout, &stderr) if code != 0 { diff --git a/internal/askcli/command_projects.go b/internal/askcli/command_projects.go index b370f45..26e407f 100644 --- a/internal/askcli/command_projects.go +++ b/internal/askcli/command_projects.go @@ -9,15 +9,12 @@ import ( "sort" ) -var ( - projectsFindTaskBinary = findTaskBinary - projectsRunTaskCommand = runTaskCommand -) +type taskCommandRunner func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error func (d *Dispatcher) handleProjects(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { _ = args - taskPath, err := projectsFindTaskBinary() + taskPath, err := d.projectTaskBinary()() if err != nil { return 1, fmt.Errorf("ask projects: task binary lookup failed: %w", err) } @@ -25,7 +22,7 @@ func (d *Dispatcher) handleProjects(ctx context.Context, args []string, stdout, scopeFilter := taskScopeFilter(taskScopeFromContext(ctx)) cmdArgs := append([]string{"rc.verbose=nothing", "rc.confirmation=off", scopeFilter, "status:pending", "export"}, args[1:]...) var outBuf bytes.Buffer - err = projectsRunTaskCommand(ctx, taskPath, cmdArgs, nil, &outBuf, stderr) + err = d.projectTaskCommand()(ctx, taskPath, cmdArgs, nil, &outBuf, stderr) if err != nil { return exitCodeFor(err), fmt.Errorf("ask projects: task export failed: %w", err) } @@ -65,3 +62,17 @@ func (d *Dispatcher) handleProjects(ctx context.Context, args []string, stdout, } return 0, nil } + +func (d *Dispatcher) projectTaskBinary() func() (string, error) { + if d != nil && d.findTaskBinary != nil { + return d.findTaskBinary + } + return findTaskBinary +} + +func (d *Dispatcher) projectTaskCommand() taskCommandRunner { + if d != nil && d.runTaskCommand != nil { + return d.runTaskCommand + } + return runTaskCommand +} diff --git a/internal/askcli/command_projects_test.go b/internal/askcli/command_projects_test.go index 3059afb..144cfe5 100644 --- a/internal/askcli/command_projects_test.go +++ b/internal/askcli/command_projects_test.go @@ -11,15 +11,9 @@ import ( ) func TestHandleProjects_ListsUniqueProjects(t *testing.T) { - oldFind := projectsFindTaskBinary - oldRun := projectsRunTaskCommand - t.Cleanup(func() { - projectsFindTaskBinary = oldFind - projectsRunTaskCommand = oldRun - }) - - projectsFindTaskBinary = func() (string, error) { return "task", nil } - projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + d := NewDispatcher(nil) + d.findTaskBinary = func() (string, error) { return "task", nil } + d.runTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { tasks := []TaskExport{ {UUID: "1", Project: "hexai", Status: "pending", Urgency: 1}, {UUID: "2", Project: "dtail", Status: "pending", Urgency: 2, Start: "2026-01-01T00:00:00Z"}, @@ -31,7 +25,6 @@ func TestHandleProjects_ListsUniqueProjects(t *testing.T) { } ctx := context.Background() - d := NewDispatcher(nil) var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"projects"}, nil, &stdout, &stderr) if err != nil { @@ -48,15 +41,9 @@ func TestHandleProjects_ListsUniqueProjects(t *testing.T) { } func TestHandleProjects_JSONOutput(t *testing.T) { - oldFind := projectsFindTaskBinary - oldRun := projectsRunTaskCommand - t.Cleanup(func() { - projectsFindTaskBinary = oldFind - projectsRunTaskCommand = oldRun - }) - - projectsFindTaskBinary = func() (string, error) { return "task", nil } - projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + d := NewDispatcher(nil) + d.findTaskBinary = func() (string, error) { return "task", nil } + d.runTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { tasks := []TaskExport{ {UUID: "1", Project: "hexai", Status: "pending", Urgency: 1}, {UUID: "2", Project: "dtail", Status: "pending", Urgency: 2}, @@ -66,7 +53,6 @@ func TestHandleProjects_JSONOutput(t *testing.T) { } ctx := context.Background() - d := NewDispatcher(nil) var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"--json", "projects"}, nil, &stdout, &stderr) if err != nil { @@ -82,21 +68,14 @@ func TestHandleProjects_JSONOutput(t *testing.T) { } func TestHandleProjects_EmptyResult(t *testing.T) { - oldFind := projectsFindTaskBinary - oldRun := projectsRunTaskCommand - t.Cleanup(func() { - projectsFindTaskBinary = oldFind - projectsRunTaskCommand = oldRun - }) - - projectsFindTaskBinary = func() (string, error) { return "task", nil } - projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + d := NewDispatcher(nil) + d.findTaskBinary = func() (string, error) { return "task", nil } + d.runTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { _, _ = io.WriteString(stdout, "[]") return nil } ctx := context.Background() - d := NewDispatcher(nil) var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"projects"}, nil, &stdout, &stderr) if err != nil { @@ -111,20 +90,13 @@ func TestHandleProjects_EmptyResult(t *testing.T) { } func TestHandleProjects_ForwardsTaskExportError(t *testing.T) { - oldFind := projectsFindTaskBinary - oldRun := projectsRunTaskCommand - t.Cleanup(func() { - projectsFindTaskBinary = oldFind - projectsRunTaskCommand = oldRun - }) - - projectsFindTaskBinary = func() (string, error) { return "task", nil } - projectsRunTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + d := NewDispatcher(nil) + d.findTaskBinary = func() (string, error) { return "task", nil } + d.runTaskCommand = func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { return fmt.Errorf("some error") } ctx := context.Background() - d := NewDispatcher(nil) var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"projects"}, nil, &stdout, &stderr) if err == nil { diff --git a/internal/askcli/command_start_deps.go b/internal/askcli/command_start_deps.go index 94a6a9e..d20bd3f 100644 --- a/internal/askcli/command_start_deps.go +++ b/internal/askcli/command_start_deps.go @@ -13,7 +13,7 @@ func (d *Dispatcher) verifyDependenciesCompletedForStart(ctx context.Context, ta if len(task.Depends) == 0 { return 0 } - aliases, err := ensureTaskAliasesForUUIDs(task.Depends) + aliases, err := d.aliasCache.withDefaults().ensureTaskAliasesForUUIDs(task.Depends) if err != nil { fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err) return 1 diff --git a/internal/askcli/command_start_deps_test.go b/internal/askcli/command_start_deps_test.go index 9ba197b..929eabc 100644 --- a/internal/askcli/command_start_deps_test.go +++ b/internal/askcli/command_start_deps_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "path/filepath" "strings" "testing" "time" @@ -12,15 +11,8 @@ import ( func TestHandleStart_BlockedWhenDependencyNotCompleted(t *testing.T) { 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 fixedNow } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + deps := testTaskAliasCacheDeps(dir, &fixedNow) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, @@ -28,7 +20,7 @@ func TestHandleStart_BlockedWhenDependencyNotCompleted(t *testing.T) { {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow}, {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow}, }, - }) + }, deps) var startCalls int d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -47,6 +39,7 @@ func TestHandleStart_BlockedWhenDependencyNotCompleted(t *testing.T) { return 1, nil } }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr) @@ -66,15 +59,8 @@ func TestHandleStart_BlockedWhenDependencyNotCompleted(t *testing.T) { func TestHandleStart_AllowedWhenDependencyCompleted(t *testing.T) { 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 fixedNow } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + deps := testTaskAliasCacheDeps(dir, &fixedNow) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, @@ -82,7 +68,7 @@ func TestHandleStart_AllowedWhenDependencyCompleted(t *testing.T) { {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow}, {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow}, }, - }) + }, deps) d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { switch { @@ -99,6 +85,7 @@ func TestHandleStart_AllowedWhenDependencyCompleted(t *testing.T) { return 1, nil } }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr) @@ -112,15 +99,8 @@ func TestHandleStart_AllowedWhenDependencyCompleted(t *testing.T) { func TestHandleStart_CompletedStatusIsCaseInsensitive(t *testing.T) { 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 fixedNow } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + deps := testTaskAliasCacheDeps(dir, &fixedNow) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, @@ -128,7 +108,7 @@ func TestHandleStart_CompletedStatusIsCaseInsensitive(t *testing.T) { {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow}, {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow}, }, - }) + }, deps) d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { switch { @@ -145,6 +125,7 @@ func TestHandleStart_CompletedStatusIsCaseInsensitive(t *testing.T) { return 1, nil } }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr) diff --git a/internal/askcli/command_urgency.go b/internal/askcli/command_urgency.go index bc7d4fd..bf7940f 100644 --- a/internal/askcli/command_urgency.go +++ b/internal/askcli/command_urgency.go @@ -23,5 +23,5 @@ func (d *Dispatcher) handleUrgency(ctx context.Context, args []string, stdout, s sort.Slice(tasks, func(i, j int) bool { return tasks[i].Urgency > tasks[j].Urgency }) - return renderTaskList(tasks, stdout, stderr, d.jsonOutput) + return renderTaskListWithAliasLoader(tasks, stdout, stderr, d.jsonOutput, d.aliasCache.withDefaults().ensureTaskAliases) } diff --git a/internal/askcli/command_urgency_test.go b/internal/askcli/command_urgency_test.go index 2582e58..1ad323e 100644 --- a/internal/askcli/command_urgency_test.go +++ b/internal/askcli/command_urgency_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "path/filepath" "strings" "testing" "time" @@ -12,28 +11,23 @@ import ( func TestHandleUrgency_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 := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) + deps := testTaskAliasCacheDeps(dir, &now) writeTaskAliasCacheForTest(t, taskAliasCache{ NextID: 2, Entries: []taskAliasCacheEntry{ - {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()}, - {UUID: "uuid-2", Alias: "1", CreatedAt: nowTaskAliasCache()}, + {UUID: "uuid-1", Alias: "0", CreatedAt: now}, + {UUID: "uuid-2", Alias: "1", CreatedAt: now}, }, - }) + }, deps) jsonData := `[{"uuid":"uuid-2","description":"Task 2","status":"pending","priority":"M","tags":["agent"],"urgency":10.0,"depends":[]},{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { _, _ = io.WriteString(stdout, jsonData) return 0, nil }}) + d.aliasCache = deps var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"urgency"}, nil, &stdout, &stderr) if code != 0 { diff --git a/internal/askcli/command_watch_test.go b/internal/askcli/command_watch_test.go index 7618060..d742a4b 100644 --- a/internal/askcli/command_watch_test.go +++ b/internal/askcli/command_watch_test.go @@ -5,7 +5,6 @@ import ( "context" "errors" "io" - "path/filepath" "reflect" "strings" "testing" @@ -100,14 +99,7 @@ func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) { func TestHandleWatch_DefaultsToListAndRedrawsOnChange(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, 5, 26, 12, 0, 0, 0, time.UTC) } - t.Cleanup(func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }) + t.Setenv("XDG_CACHE_HOME", dir) ticks := make(chan time.Time, 1) ticks <- time.Now() @@ -157,14 +149,7 @@ func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) { func TestHandleWatch_DoesNotRedrawUnchangedOutput(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, 5, 26, 12, 0, 0, 0, time.UTC) } - t.Cleanup(func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }) + t.Setenv("XDG_CACHE_HOME", dir) ticks := make(chan time.Time, 1) ticks <- time.Now() diff --git a/internal/askcli/command_write_test.go b/internal/askcli/command_write_test.go index e922920..453b005 100644 --- a/internal/askcli/command_write_test.go +++ b/internal/askcli/command_write_test.go @@ -5,7 +5,6 @@ import ( "context" "errors" "io" - "path/filepath" "strings" "testing" "time" @@ -15,15 +14,8 @@ 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 fixedNow } - t.Cleanup(func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }) + t.Setenv("XDG_CACHE_HOME", dir) return fixedNow } diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index cdfc1d8..c1976f1 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -20,10 +20,13 @@ type Runner interface { // uses the real time.Ticker-backed factory installed by NewDispatcher; tests // inject a fake ticker so they can drive the watch loop without real delays. type Dispatcher struct { - runner Runner - jsonOutput bool - newTicker func(time.Duration) watchTicker - capture func(context.Context, []byte) (string, error) + runner Runner + jsonOutput bool + newTicker func(time.Duration) watchTicker + capture func(context.Context, []byte) (string, error) + aliasCache taskAliasCacheDeps + findTaskBinary func() (string, error) + runTaskCommand taskCommandRunner } // NewDispatcher creates a Dispatcher backed by the provided Runner or a default @@ -35,9 +38,12 @@ func NewDispatcher(runner Runner) *Dispatcher { runner = &e } return &Dispatcher{ - runner: runner, - newTicker: newRealWatchTicker, - capture: editorCapture, + runner: runner, + newTicker: newRealWatchTicker, + capture: editorCapture, + aliasCache: defaultTaskAliasCacheDeps(), + findTaskBinary: findTaskBinary, + runTaskCommand: runTaskCommand, } } diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 2b00a0e..589e956 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -5,11 +5,9 @@ import ( "context" "io" "os" - "path/filepath" "reflect" "strings" "testing" - "time" ) func TestDispatcher_Help(t *testing.T) { @@ -45,14 +43,7 @@ func TestDispatcher_Help(t *testing.T) { func TestDispatcher_DefaultsInvalidSubcommandToAdd(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, 4, 22, 12, 0, 0, 0, time.UTC) } - defer func() { - taskAliasCacheRoot = oldRoot - nowTaskAliasCache = oldNow - }() + t.Setenv("XDG_CACHE_HOME", dir) tests := []struct { name string @@ -136,14 +127,7 @@ func TestDispatcher_CompleteUUIDsSubcommand(t *testing.T) { // Use a temp dir for the alias cache so this test is hermetic and does // not depend on cache state left by other tests. 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 - }() + t.Setenv("XDG_CACHE_HOME", dir) 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" { @@ -168,14 +152,7 @@ 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 - }() + t.Setenv("XDG_CACHE_HOME", dir) 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" { @@ -448,14 +425,7 @@ func TestDispatcher_NoAgentPrefix_StripsScopePrefix(t *testing.T) { func TestDispatcher_AllSubcommandsReachExecutor(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 - }() + t.Setenv("XDG_CACHE_HOME", dir) taskJSONFor := func(uuid string) string { return `[{"uuid":"` + uuid + `","description":"Test","status":"pending","priority":"M","tags":[],"urgency":10,"depends":[]}]` diff --git a/internal/askcli/render_task_list.go b/internal/askcli/render_task_list.go index 177bd81..65cae71 100644 --- a/internal/askcli/render_task_list.go +++ b/internal/askcli/render_task_list.go @@ -6,10 +6,14 @@ import ( "io" ) -var taskListAliasLoader = ensureTaskAliases +type taskListAliasLoader func([]TaskExport) (map[str