From db8c5659cb54cbfd0ac1e59888059d45b4fe4d3d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 26 May 2026 10:52:01 +0300 Subject: feat(askcli): add projects subcommand --- internal/askcli/command_projects.go | 67 ++++++++++++++ internal/askcli/command_projects_test.go | 144 +++++++++++++++++++++++++++++++ internal/askcli/commands_registry.go | 7 ++ internal/askcli/dispatch.go | 1 + internal/askcli/dispatch_test.go | 2 +- internal/askcli/taskexport.go | 1 + 6 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 internal/askcli/command_projects.go create mode 100644 internal/askcli/command_projects_test.go (limited to 'internal/askcli') diff --git a/internal/askcli/command_projects.go b/internal/askcli/command_projects.go new file mode 100644 index 0000000..b370f45 --- /dev/null +++ b/internal/askcli/command_projects.go @@ -0,0 +1,67 @@ +package askcli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "sort" +) + +var ( + projectsFindTaskBinary = findTaskBinary + projectsRunTaskCommand = runTaskCommand +) + +func (d *Dispatcher) handleProjects(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { + _ = args + + taskPath, err := projectsFindTaskBinary() + if err != nil { + return 1, fmt.Errorf("ask projects: task binary lookup failed: %w", err) + } + + 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) + if err != nil { + return exitCodeFor(err), fmt.Errorf("ask projects: task export failed: %w", err) + } + + tasks, err := ParseTaskExport(&outBuf) + if err != nil { + fmt.Fprintf(stderr, "error: failed to parse task data: %v\n", err) + return 1, nil + } + + projectSet := make(map[string]struct{}) + for _, task := range tasks { + if task.Status == "pending" && task.Start == "" && task.Project != "" { + projectSet[task.Project] = struct{}{} + } + } + + projects := make([]string, 0, len(projectSet)) + for p := range projectSet { + projects = append(projects, p) + } + sort.Strings(projects) + + if d.jsonOutput { + data, err := json.Marshal(projects) + if err != nil { + fmt.Fprintf(stderr, "error: failed to marshal JSON: %v\n", err) + return 1, nil + } + _, _ = stdout.Write(data) + _, _ = io.WriteString(stdout, "\n") + return 0, nil + } + + for _, p := range projects { + _, _ = io.WriteString(stdout, p+"\n") + } + return 0, nil +} diff --git a/internal/askcli/command_projects_test.go b/internal/askcli/command_projects_test.go new file mode 100644 index 0000000..3059afb --- /dev/null +++ b/internal/askcli/command_projects_test.go @@ -0,0 +1,144 @@ +package askcli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" +) + +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 { + tasks := []TaskExport{ + {UUID: "1", Project: "hexai", Status: "pending", Urgency: 1}, + {UUID: "2", Project: "dtail", Status: "pending", Urgency: 2, Start: "2026-01-01T00:00:00Z"}, + {UUID: "3", Project: "hexai", Status: "pending", Urgency: 3}, + {UUID: "4", Project: "", Status: "pending", Urgency: 4}, + } + _, _ = io.WriteString(stdout, taskExportJSON(tasks)) + 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 { + t.Fatalf("unexpected error: %v", err) + } + if code != 0 { + t.Fatalf("unexpected exit code: %d", code) + } + + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + if len(lines) != 1 || lines[0] != "hexai" { + t.Fatalf("expected [hexai], got %q", lines) + } +} + +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 { + tasks := []TaskExport{ + {UUID: "1", Project: "hexai", Status: "pending", Urgency: 1}, + {UUID: "2", Project: "dtail", Status: "pending", Urgency: 2}, + } + _, _ = io.WriteString(stdout, taskExportJSON(tasks)) + return nil + } + + 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 { + t.Fatalf("unexpected error: %v", err) + } + if code != 0 { + t.Fatalf("unexpected exit code: %d", code) + } + + if !strings.Contains(stdout.String(), `"dtail"`) || !strings.Contains(stdout.String(), `"hexai"`) { + t.Fatalf("unexpected JSON output: %s", stdout.String()) + } +} + +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 { + _, _ = 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 { + t.Fatalf("unexpected error: %v", err) + } + if code != 0 { + t.Fatalf("unexpected exit code: %d", code) + } + if stdout.String() != "" { + t.Fatalf("expected no output, got %q", stdout.String()) + } +} + +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 { + 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 { + t.Fatal("expected error") + } + if code != 1 { + t.Fatalf("expected exit code 1, got %d", code) + } +} + +func taskExportJSON(tasks []TaskExport) string { + data, err := json.Marshal(tasks) + if err != nil { + panic(err) + } + return string(data) +} diff --git a/internal/askcli/commands_registry.go b/internal/askcli/commands_registry.go index f999ca3..b288fd4 100644 --- a/internal/askcli/commands_registry.go +++ b/internal/askcli/commands_registry.go @@ -186,6 +186,13 @@ var commandRegistry = newCommandTable([]commandEntry{ }) func init() { + commandRegistry.add(commandEntry{ + name: "projects", + description: "List projects with pending, not-yet-started tasks", + handler: wrapSimpleCommand((*Dispatcher).handleProjects), + includeInCompletion: true, + readOnly: true, + }) commandRegistry.add(commandEntry{ name: "watch", description: "Repeatedly run a subcommand and redraw when output changes", diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index 2cdaa93..f6c503b 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -95,6 +95,7 @@ func (d *Dispatcher) help(w io.Writer) (int, error) { _, _ = io.WriteString(w, " ask dep list List dependencies\n") _, _ = io.WriteString(w, " ask urgency List tasks sorted by urgency\n") _, _ = io.WriteString(w, " ask watch [subcommand...] Re-run a read-only subcommand every 2s and redraw on changes\n") + _, _ = io.WriteString(w, " ask projects List projects with pending, not-yet-started tasks\n") _, _ = io.WriteString(w, " ask modify Modify task fields\n") _, _ = io.WriteString(w, " ask denotate \"text\" Remove annotation\n") _, _ = io.WriteString(w, " ask delete Delete a task\n") diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 986b5e1..c7c7086 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -203,7 +203,7 @@ func TestDispatcher_LongHelp(t *testing.T) { var stdout bytes.Buffer d.Dispatch(context.Background(), []string{"help"}, nil, &stdout, io.Discard) output := stdout.String() - for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "watch", "modify", "denotate", "delete", "fish"} { + for _, sub := range []string{"add", "list", "all", "ready", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "watch", "projects", "modify", "denotate", "delete", "fish"} { if !strings.Contains(output, "ask "+sub) { t.Errorf("help missing subcommand: ask %s", sub) } diff --git a/internal/askcli/taskexport.go b/internal/askcli/taskexport.go index be24117..ff9a499 100644 --- a/internal/askcli/taskexport.go +++ b/internal/askcli/taskexport.go @@ -9,6 +9,7 @@ import ( // TaskExport mirrors the JSON structure returned by Taskwarrior export commands. type TaskExport struct { UUID string `json:"uuid"` + Project string `json:"project,omitempty"` Description string `json:"description"` Status string `json:"status"` Priority string `json:"priority"` -- cgit v1.2.3