summaryrefslogtreecommitdiff
path: root/internal/askcli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/askcli')
-rw-r--r--internal/askcli/command_projects.go67
-rw-r--r--internal/askcli/command_projects_test.go144
-rw-r--r--internal/askcli/commands_registry.go7
-rw-r--r--internal/askcli/dispatch.go1
-rw-r--r--internal/askcli/dispatch_test.go2
-rw-r--r--internal/askcli/taskexport.go1
6 files changed, 221 insertions, 1 deletions
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
@@ -187,6 +187,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",
handler: wrapSimpleCommand((*Dispatcher).handleWatch),
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 <id|uuid> 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 <id|uuid> <args...> Modify task fields\n")
_, _ = io.WriteString(w, " ask denotate <id|uuid> \"text\" Remove annotation\n")
_, _ = io.WriteString(w, " ask delete <id|uuid> 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"`