diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-22 19:39:33 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-22 19:39:33 +0200 |
| commit | 3f06d7dadb83d78f0913b1c1c9a9297826e107b1 (patch) | |
| tree | 0b2bf1466c3cd6d949abd00d70c6f07b4588200a | |
| parent | 487ee8b3262ca1845b931d1f0b9df9966fbedea3 (diff) | |
Scaffold internal/askcli package: dispatch, taskexec, taskexport, formatter
| -rw-r--r-- | internal/askcli/dispatch.go | 67 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 83 | ||||
| -rw-r--r-- | internal/askcli/formatter.go | 77 | ||||
| -rw-r--r-- | internal/askcli/formatter_test.go | 143 | ||||
| -rw-r--r-- | internal/askcli/taskexec.go | 129 | ||||
| -rw-r--r-- | internal/askcli/taskexec_test.go | 153 | ||||
| -rw-r--r-- | internal/askcli/taskexport.go | 68 | ||||
| -rw-r--r-- | internal/askcli/taskexport_test.go | 146 |
8 files changed, 866 insertions, 0 deletions
diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go new file mode 100644 index 0000000..b764618 --- /dev/null +++ b/internal/askcli/dispatch.go @@ -0,0 +1,67 @@ +package askcli + +import ( + "context" + "fmt" + "io" +) + +type Runner interface { + Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) +} + +type Dispatcher struct { + runner Runner +} + +func NewDispatcher(runner Runner) *Dispatcher { + if runner == nil { + e := NewExecutor("ask") + runner = &e + } + return &Dispatcher{runner: runner} +} + +func (d Dispatcher) Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if len(args) == 0 { + return d.help(stdout) + } + subcommand := args[0] + switch subcommand { + case "add", "list", "info", "annotate", "start", "stop", "done", + "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "export": + return d.runner.Run(ctx, args, stdin, stdout, stderr) + default: + return d.unknownCommand(stderr, subcommand) + } +} + +func (d Dispatcher) help(w io.Writer) (int, error) { + io.WriteString(w, "ask - task management CLI\n") + io.WriteString(w, "\nSubcommands:\n") + io.WriteString(w, " ask add \"description\" Create a new task\n") + io.WriteString(w, " ask list [filters] List tasks (UUID-only output)\n") + io.WriteString(w, " ask info <uuid> Show task details\n") + io.WriteString(w, " ask annotate <uuid> \"note\" Add annotation to task\n") + io.WriteString(w, " ask start <uuid> Start working on task\n") + io.WriteString(w, " ask stop <uuid> Stop working on task\n") + io.WriteString(w, " ask done <uuid> Mark task complete\n") + io.WriteString(w, " ask priority <uuid> <P> Set priority (H/M/L)\n") + io.WriteString(w, " ask tag <uuid> +/-<tag> Add or remove tag\n") + io.WriteString(w, " ask dep add <uuid> <dep-uuid> Add dependency\n") + io.WriteString(w, " ask dep rm <uuid> <dep-uuid> Remove dependency\n") + io.WriteString(w, " ask dep list <uuid> List dependencies\n") + io.WriteString(w, " ask urgency List tasks sorted by urgency\n") + io.WriteString(w, " ask modify <uuid> <args...> Modify task fields\n") + io.WriteString(w, " ask denotate <uuid> \"text\" Remove annotation\n") + io.WriteString(w, " ask delete <uuid> Delete task\n") + io.WriteString(w, " ask export Raw JSON export\n") + io.WriteString(w, "\nFilters:\n") + io.WriteString(w, " +READY +BLOCKED +<tag> started limit:N sort:priority-,urgency-\n") + return 0, nil +} + +func (d Dispatcher) unknownCommand(w io.Writer, subcommand string) (int, error) { + fmt.Fprintf(w, "ask: unknown subcommand %q\n", subcommand) + return 1, nil +} diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go new file mode 100644 index 0000000..f4c27a4 --- /dev/null +++ b/internal/askcli/dispatch_test.go @@ -0,0 +1,83 @@ +package askcli + +import ( + "bytes" + "context" + "io" + "strings" + "testing" +) + +func TestDispatcher_Help(t *testing.T) { + d := NewDispatcher(nil) + var stdout bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{}, nil, &stdout, io.Discard) + if code != 0 { + t.Fatalf("help exit code = %d, want 0", code) + } + if err != nil { + t.Fatalf("help returned error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "ask - task management CLI") { + t.Fatalf("help missing title: %s", output) + } + if !strings.Contains(output, "ask list") { + t.Fatalf("help missing list subcommand: %s", output) + } + if !strings.Contains(output, "Filters:") { + t.Fatalf("help missing Filters section: %s", output) + } +} + +func TestDispatcher_UnknownSubcommand(t *testing.T) { + d := NewDispatcher(nil) + var stderr bytes.Buffer + code, err := d.Dispatch(context.Background(), []string{"foobar"}, nil, io.Discard, &stderr) + if code != 1 { + t.Fatalf("unknown subcommand exit code = %d, want 1", code) + } + if err != nil { + t.Fatalf("unknown subcommand returned unexpected error: %v", err) + } + output := stderr.String() + if !strings.Contains(output, "unknown subcommand") { + t.Fatalf("unknown subcommand output missing: %s", output) + } +} + +func TestDispatcher_LongHelp(t *testing.T) { + d := NewDispatcher(nil) + var stdout bytes.Buffer + d.Dispatch(context.Background(), []string{}, nil, &stdout, io.Discard) + output := stdout.String() + for _, sub := range []string{"add", "list", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "export"} { + if !strings.Contains(output, "ask "+sub) { + t.Errorf("help missing subcommand: ask %s", sub) + } + } +} + +func TestDispatcher_AllSubcommandsReachExecutor(t *testing.T) { + subcommands := []string{"add", "list", "info", "annotate", "start", "stop", "done", "priority", "tag", "dep", "urgency", "modify", "denotate", "delete", "export"} + for _, sub := range subcommands { + var stdout, stderr bytes.Buffer + calls := 0 + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout_, stderr_ io.Writer) (int, error) { + calls++ + return 0, nil + }}) + code, _ := d.Dispatch(context.Background(), []string{sub}, nil, &stdout, &stderr) + if code != 0 { + t.Errorf("subcommand %q code = %d, want 0", sub, code) + } + } +} + +type spyRunner struct { + runFn func(context.Context, []string, io.Reader, io.Writer, io.Writer) (int, error) +} + +func (s *spyRunner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + return s.runFn(ctx, args, stdin, stdout, stderr) +} diff --git a/internal/askcli/formatter.go b/internal/askcli/formatter.go new file mode 100644 index 0000000..e210dc7 --- /dev/null +++ b/internal/askcli/formatter.go @@ -0,0 +1,77 @@ +package askcli + +import ( + "fmt" + "io" + "strings" +) + +func FormatTaskList(tasks []TaskExport) string { + var b strings.Builder + io.WriteString(&b, "UUID | Priority | Status | Tags | Description | Urgency\n") + io.WriteString(&b, strings.Repeat("-", 120)+"\n") + for _, t := range tasks { + tags := strings.Join(t.Tags, ",") + if tags == "" { + tags = "-" + } + desc := t.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + fmt.Fprintf(&b, "%s | %s | %s | %s | %s | %.1f\n", t.UUID, t.Priority, t.Status, tags, desc, t.Urgency) + } + return b.String() +} + +func FormatTaskInfo(t TaskExport) string { + var b strings.Builder + fmt.Fprintf(&b, "UUID: %s\n", t.UUID) + fmt.Fprintf(&b, "Description: %s\n", t.Description) + fmt.Fprintf(&b, "Status: %s\n", t.Status) + fmt.Fprintf(&b, "Priority: %s\n", t.Priority) + fmt.Fprintf(&b, "Urgency: %.1f\n", t.Urgency) + if len(t.Tags) > 0 { + fmt.Fprintf(&b, "Tags: %s\n", strings.Join(t.Tags, ", ")) + } + if t.Start != "" { + fmt.Fprintf(&b, "Started: %s\n", t.Start) + } + if len(t.Depends) > 0 { + fmt.Fprintf(&b, "Depends: %s\n", strings.Join(t.Depends, ", ")) + } + if len(t.Annotations) > 0 { + io.WriteString(&b, "Annotations:\n") + for _, a := range t.Annotations { + fmt.Fprintf(&b, " - %s (added %s)\n", a.Description, a.Entry) + } + } + return b.String() +} + +func FormatSuccess(uuid string) string { + return fmt.Sprintf("ok %s\n", uuid) +} + +func FormatError(err error, uuid string) string { + if uuid != "" { + return fmt.Sprintf("error %s: %v\n", uuid, err) + } + return fmt.Sprintf("error: %v\n", err) +} + +func IsNumericID(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +func RejectNumericID() string { + return "error: use UUID, not numeric task ID\n" +} diff --git a/internal/askcli/formatter_test.go b/internal/askcli/formatter_test.go new file mode 100644 index 0000000..394ea91 --- /dev/null +++ b/internal/askcli/formatter_test.go @@ -0,0 +1,143 @@ +package askcli + +import ( + "strings" + "testing" +) + +func TestFormatTaskList(t *testing.T) { + tasks := []TaskExport{ + {UUID: "uuid-1", Description: "Short task", Status: "pending", Priority: "H", Tags: []string{"cli"}, Urgency: 15.0}, + {UUID: "uuid-2", Description: strings.Repeat("a", 100), Status: "completed", Priority: "M", Tags: []string{"agent", "test"}, Urgency: 5.0}, + {UUID: "uuid-3", Description: "No tags task", Status: "waiting", Priority: "L", Tags: []string{}, Urgency: 8.0}, + } + output := FormatTaskList(tasks) + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 3 { + t.Fatalf("FormatTaskList produced too few lines: %d", len(lines)) + } + if !strings.Contains(lines[0], "UUID") || !strings.Contains(lines[0], "Priority") { + t.Fatalf("header missing UUID or Priority column: %s", lines[0]) + } + if !strings.Contains(lines[2], "uuid-1") { + t.Fatalf("first task line missing uuid-1: %s", lines[2]) + } + if strings.Contains(lines[2], "...") { + t.Fatalf("long description should be truncated with ...: %s", lines[2]) + } +} + +func TestFormatTaskInfo(t *testing.T) { + task := TaskExport{ + UUID: "test-uuid", + Description: "Test description", + Status: "pending", + Priority: "H", + Tags: []string{"cli", "agent"}, + Start: "2026-03-22T10:00:00Z", + Urgency: 15.5, + Depends: []string{"dep-1", "dep-2"}, + Annotations: []struct { + Description string `json:"description"` + Entry string `json:"entry"` + }{ + {Description: "First note", Entry: "2026-03-22T11:00:00Z"}, + }, + } + output := FormatTaskInfo(task) + if !strings.Contains(output, "test-uuid") { + t.Fatalf("FormatTaskInfo missing UUID: %s", output) + } + if !strings.Contains(output, "H") { + t.Fatalf("FormatTaskInfo missing priority H: %s", output) + } + if !strings.Contains(output, "cli, agent") { + t.Fatalf("FormatTaskInfo missing tags: %s", output) + } + if !strings.Contains(output, "dep-1") { + t.Fatalf("FormatTaskInfo missing depends: %s", output) + } + if !strings.Contains(output, "First note") { + t.Fatalf("FormatTaskInfo missing annotation: %s", output) + } +} + +func TestFormatSuccess(t *testing.T) { + output := FormatSuccess("test-uuid") + if !strings.Contains(output, "ok") || !strings.Contains(output, "test-uuid") { + t.Fatalf("FormatSuccess = %q, want ok + uuid", output) + } +} + +func TestFormatError(t *testing.T) { + err := &testError{msg: "something went wrong"} + output := FormatError(err, "uuid-123") + if !strings.Contains(output, "error") || !strings.Contains(output, "uuid-123") || !strings.Contains(output, "something went wrong") { + t.Fatalf("FormatError = %q, want error + uuid + message", output) + } +} + +func TestFormatError_NoUUID(t *testing.T) { + err := &testError{msg: "generic error"} + output := FormatError(err, "") + if !strings.Contains(output, "error") || !strings.Contains(output, "generic error") { + t.Fatalf("FormatError = %q, want error + message", output) + } +} + +func TestIsNumericID(t *testing.T) { + if !IsNumericID("123") { + t.Error("IsNumericID(\"123\") = false, want true") + } + if !IsNumericID("0") { + t.Error("IsNumericID(\"0\") = false, want true") + } + if IsNumericID("uuid-123") { + t.Error("IsNumericID(\"uuid-123\") = true, want false") + } + if IsNumericID("abc") { + t.Error("IsNumericID(\"abc\") = true, want false") + } + if IsNumericID("12a") { + t.Error("IsNumericID(\"12a\") = true, want false") + } + if IsNumericID("") { + t.Error("IsNumericID(\"\") = true, want false") + } +} + +func TestRejectNumericID(t *testing.T) { + output := RejectNumericID() + if !strings.Contains(output, "use UUID") { + t.Fatalf("RejectNumericID = %q, want use UUID message", output) + } +} + +func TestFormatTaskInfo_NoOptionalFields(t *testing.T) { + task := TaskExport{ + UUID: "simple-uuid", + Description: "Simple task", + Status: "pending", + Priority: "M", + Tags: []string{}, + Urgency: 0, + } + output := FormatTaskInfo(task) + if !strings.Contains(output, "simple-uuid") { + t.Fatalf("FormatTaskInfo missing UUID: %s", output) + } + if strings.Contains(output, "Tags:") { + t.Fatalf("FormatTaskInfo should not contain Tags: for empty tags: %s", output) + } + if strings.Contains(output, "Depends:") { + t.Fatalf("FormatTaskInfo should not contain Depends: for empty depends: %s", output) + } +} + +type testError struct { + msg string +} + +func (e *testError) Error() string { + return e.msg +} diff --git a/internal/askcli/taskexec.go b/internal/askcli/taskexec.go new file mode 100644 index 0000000..9c9aa69 --- /dev/null +++ b/internal/askcli/taskexec.go @@ -0,0 +1,129 @@ +package askcli + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "strings" +) + +type binaryFinder func() (string, error) + +type repoTopLevelDetector func(context.Context) (string, error) + +type commandRunner func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error + +type Executor struct { + commandName string + findBinary binaryFinder + detectRepoRoot repoTopLevelDetector + runCommand commandRunner +} + +func NewExecutor(commandName string) Executor { + return Executor{ + commandName: strings.TrimSpace(commandName), + findBinary: findTaskBinary, + detectRepoRoot: detectRepoRoot, + runCommand: runTaskCommand, + } +} + +func (e Executor) taskArgs(repoRoot string, args []string) ([]string, error) { + projectName, err := projectNameFromRoot(repoRoot) + if err != nil { + return nil, err + } + return append([]string{"project:" + projectName, "+agent"}, args...), nil +} + +func (e Executor) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + executor := normalizeExecutor(e) + taskPath, err := executor.findBinary() + if err != nil { + return 1, fmt.Errorf("%s: task binary lookup failed: %w", executor.label(), err) + } + repoRoot, err := executor.detectRepoRoot(ctx) + if err != nil { + return 1, fmt.Errorf("%s: must be run inside a git repository: %w", executor.label(), err) + } + taskArgs, err := executor.taskArgs(repoRoot, args) + if err != nil { + return 1, fmt.Errorf("%s: %w", executor.label(), err) + } + if err := executor.runCommand(ctx, taskPath, taskArgs, stdin, stdout, stderr); err != nil { + return exitCodeFor(err), nil + } + return 0, nil +} + +func (e Executor) label() string { + label := strings.TrimSpace(e.commandName) + if label == "" { + return "ask" + } + return label +} + +func normalizeExecutor(e Executor) Executor { + if e.commandName == "" { + e.commandName = "ask" + } + if e.findBinary == nil { + e.findBinary = findTaskBinary + } + if e.detectRepoRoot == nil { + e.detectRepoRoot = detectRepoRoot + } + if e.runCommand == nil { + e.runCommand = runTaskCommand + } + return e +} + +func projectNameFromRoot(repoRoot string) (string, error) { + projectName := filepath.Base(strings.TrimSpace(repoRoot)) + if projectName == "" || projectName == "." || projectName == string(filepath.Separator) { + return "", fmt.Errorf("could not derive project name from git root %q", repoRoot) + } + return projectName, nil +} + +func findTaskBinary() (string, error) { + path, err := exec.LookPath("task") + if err != nil { + return "", fmt.Errorf("task binary 'task' not found in PATH; install task and retry") + } + return path, nil +} + +func detectRepoRoot(ctx context.Context) (string, error) { + out, err := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", fmt.Errorf("must be run inside a git repository so project name can be derived") + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "", fmt.Errorf("git returned an empty repository root") + } + return root, nil +} + +func runTaskCommand(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + +func exitCodeFor(err error) int { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return 1 +} diff --git a/internal/askcli/taskexec_test.go b/internal/askcli/taskexec_test.go new file mode 100644 index 0000000..4450a28 --- /dev/null +++ b/internal/askcli/taskexec_test.go @@ -0,0 +1,153 @@ +package askcli + +import ( + "bytes" + "context" + "errors" + "io" + "os/exec" + "reflect" + "strings" + "testing" +) + +func TestExecutorTaskArgs(t *testing.T) { + exec_ := NewExecutor("ask") + args, err := exec_.taskArgs("/tmp/work/hexai", []string{"list", "limit:1"}) + if err != nil { + t.Fatalf("taskArgs returned error: %v", err) + } + want := []string{"project:hexai", "+agent", "list", "limit:1"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("task args = %v, want %v", args, want) + } +} + +func TestExecutorRun_InjectsProjectFilterAndAgentTag(t *testing.T) { + var gotName string + var gotArgs []string + exec_ := Executor{ + commandName: "ask", + findBinary: func() (string, error) { return "/usr/bin/task", nil }, + detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil }, + runCommand: func(_ context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + gotName = name + gotArgs = append([]string(nil), args...) + return nil + }, + } + + exitCode, err := exec_.Run(context.Background(), []string{"list", "limit:1"}, strings.NewReader("in"), &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if exitCode != 0 { + t.Fatalf("exitCode = %d, want 0", exitCode) + } + if gotName != "/usr/bin/task" { + t.Fatalf("task binary = %q, want /usr/bin/task", gotName) + } + wantArgs := []string{"project:hexai", "+agent", "list", "limit:1"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("task args = %v, want %v", gotArgs, wantArgs) + } +} + +func TestExecutorRun_OutsideGitRepo_IsActionable(t *testing.T) { + exec_ := Executor{ + commandName: "ask", + findBinary: func() (string, error) { return "/usr/bin/task", nil }, + detectRepoRoot: func(context.Context) (string, error) { return "", errors.New("git failed") }, + runCommand: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error { + t.Fatal("runCommand should not be called when repo detection fails") + return nil + }, + } + + exitCode, err := exec_.Run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + if exitCode != 1 { + t.Fatalf("exitCode = %d, want 1", exitCode) + } + if err == nil || !strings.Contains(err.Error(), "must be run inside a git repository") { + t.Fatalf("expected actionable git-repo error, got %v", err) + } +} + +func TestExecutorRun_PreservesTaskwarriorExitCode(t *testing.T) { + exec_ := Executor{ + commandName: "ask", + findBinary: func() (string, error) { return "/usr/bin/task", nil }, + detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil }, + runCommand: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error { + return exec.Command("sh", "-c", "exit 7").Run() + }, + } + + exitCode, err := exec_.Run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("expected nil error for subprocess exit, got %v", err) + } + if exitCode != 7 { + t.Fatalf("exitCode = %d, want 7", exitCode) + } +} + +func TestExecutorRun_PreservesStdoutAndStderr(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exec_ := Executor{ + commandName: "ask", + findBinary: func() (string, error) { return "/usr/bin/task", nil }, + detectRepoRoot: func(context.Context) (string, error) { return "/tmp/work/hexai", nil }, + runCommand: func(_ context.Context, name string, args []string, stdin io.Reader, out, errOut io.Writer) error { + _, _ = io.WriteString(out, "task stdout") + _, _ = io.WriteString(errOut, "task stderr") + return nil + }, + } + + exitCode, err := exec_.Run(context.Background(), []string{"list"}, strings.NewReader(""), &stdout, &stderr) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if exitCode != 0 { + t.Fatalf("exitCode = %d, want 0", exitCode) + } + if stdout.String() != "task stdout" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "task stdout") + } + if stderr.String() != "task stderr" { + t.Fatalf("stderr = %q, want %q", stderr.String(), "task stderr") + } +} + +func TestExecutorRun_TaskLookupFailure_IsActionable(t *testing.T) { + exec_ := Executor{ + commandName: "ask", + findBinary: func() (string, error) { return "", errors.New("not found") }, + } + + exitCode, err := exec_.Run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + if exitCode != 1 { + t.Fatalf("exitCode = %d, want 1", exitCode) + } + if err == nil || !strings.Contains(err.Error(), "task binary lookup failed") { + t.Fatalf("expected actionable task lookup error, got %v", err) + } +} + +func TestExecutorRun_EmptyRepoName_IsActionable(t *testing.T) { + exec_ := Executor{ + commandName: "ask", + findBinary: func() (string, error) { return "/usr/bin/task", nil }, + detectRepoRoot: func(context.Context) (string, error) { return "/", nil }, + } + + exitCode, err := exec_.Run(context.Background(), []string{"list"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) + if exitCode != 1 { + t.Fatalf("exitCode = %d, want 1", exitCode) + } + if err == nil || !strings.Contains(err.Error(), "could not derive project name") { + t.Fatalf("expected actionable project-name error, got %v", err) + } +} diff --git a/internal/askcli/taskexport.go b/internal/askcli/taskexport.go new file mode 100644 index 0000000..27fd3f1 --- /dev/null +++ b/internal/askcli/taskexport.go @@ -0,0 +1,68 @@ +package askcli + +import ( + "encoding/json" + "fmt" + "io" + "strings" +) + +type TaskExport struct { + UUID string `json:"uuid"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Tags []string `json:"tags"` + Start string `json:"start,omitempty"` + Urgency float64 `json:"urgency"` + Depends []string `json:"depends"` + Annotations []struct { + Description string `json:"description"` + Entry string `json:"entry"` + } `json:"annotations"` +} + +func ParseTaskExport(r io.Reader) ([]TaskExport, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("failed to read task export data: %w", err) + } + var tasks []TaskExport + if err := json.Unmarshal(data, &tasks); err != nil { + return nil, fmt.Errorf("failed to parse task export JSON: %w", err) + } + return tasks, nil +} + +func MustParseTaskExport(data []byte) []TaskExport { + var tasks []TaskExport + if err := json.Unmarshal(data, &tasks); err != nil { + panic(fmt.Sprintf("failed to parse task export JSON: %v", err)) + } + return tasks +} + +func ExtractUUIDFromOutput(output string) string { + lines := strings.Split(strings.TrimSpace(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "Created task ") { + parts := strings.Fields(line) + if len(parts) >= 3 { + return strings.TrimSuffix(parts[2], ".") + } + } + } + fields := strings.Fields(output) + for i, f := range fields { + if f == "uuid" && i+1 < len(fields) { + return fields[i+1] + } + if strings.HasPrefix(f, "Created task") { + parts := strings.Split(f, " ") + if len(parts) >= 2 { + return strings.TrimSuffix(parts[1], ".") + } + } + } + return strings.TrimSpace(output) +} diff --git a/internal/askcli/taskexport_test.go b/internal/askcli/taskexport_test.go new file mode 100644 index 0000000..9474b99 --- /dev/null +++ b/internal/askcli/taskexport_test.go @@ -0,0 +1,146 @@ +package askcli + +import ( + "encoding/json" + "io" + "strings" + "testing" +) + +func TestParseTaskExport_ValidJSON(t *testing.T) { + data := `[{"uuid":"abc123","description":"Test task","status":"pending","priority":"M","tags":["cli"],"urgency":10.5,"depends":[]}]` + tasks, err := ParseTaskExport(strings.NewReader(data)) + if err != nil { + t.Fatalf("ParseTaskExport returned error: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("len(tasks) = %d, want 1", len(tasks)) + } + if tasks[0].UUID != "abc123" { + t.Fatalf("tasks[0].UUID = %q, want %q", tasks[0].UUID, "abc123") + } + if tasks[0].Urgency != 10.5 { + t.Fatalf("tasks[0].Urgency = %f, want %f", tasks[0].Urgency, 10.5) + } +} + +func TestParseTaskExport_InvalidJSON(t *testing.T) { + _, err := ParseTaskExport(strings.NewReader("not json")) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestMustParseTaskExport_Panics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("MustParseTaskExport should panic on invalid JSON") + } + }() + MustParseTaskExport([]byte("not json")) +} + +func TestMustParseTaskExport_ValidJSON(t *testing.T) { + data := []byte(`[{"uuid":"xyz789","description":"Another task","status":"completed","priority":"H","tags":["agent"],"urgency":15.0,"depends":["dep1"]}]`) + tasks := MustParseTaskExport(data) + if len(tasks) != 1 { + t.Fatalf("len(tasks) = %d, want 1", len(tasks)) + } + if tasks[0].UUID != "xyz789" { + t.Fatalf("tasks[0].UUID = %q, want %q", tasks[0].UUID, "xyz789") + } +} + +func TestExtractUUIDFromOutput_CreatedTask(t *testing.T) { + output := "Created task 123.\nUUID: abc-123-def" + uuid := ExtractUUIDFromOutput(output) + if uuid != "123" { + t.Fatalf("ExtractUUIDFromOutput = %q, want %q", uuid, "123") + } +} + +func TestExtractUUIDFromOutput_UUIDField(t *testing.T) { + output := "Some text\nuuid abc-123-def\nmore text" + uuid := ExtractUUIDFromOutput(output) + if uuid != "abc-123-def" { + t.Fatalf("ExtractUUIDFromOutput = %q, want %q", uuid, "abc-123-def") + } +} + +func TestExtractUUIDFromOutput_PlainText(t *testing.T) { + output := "abc-456-xyz" + uuid := ExtractUUIDFromOutput(output) + if uuid != output { + t.Fatalf("ExtractUUIDFromOutput = %q, want %q", uuid, output) + } +} + +func TestTaskExport_JSONRoundTrip(t *testing.T) { + original := TaskExport{ + UUID: "test-uuid", + Description: "Test description", + Status: "pending", + Priority: "H", + Tags: []string{"cli", "agent"}, + Urgency: 12.5, + Depends: []string{"dep1", "dep2"}, + } + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + var parsed TaskExport + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if parsed.UUID != original.UUID { + t.Fatalf("parsed.UUID = %q, want %q", parsed.UUID, original.UUID) + } + if parsed.Priority != original.Priority { + t.Fatalf("parsed.Priority = %q, want %q", parsed.Priority, original.Priority) + } + if len(parsed.Depends) != len(original.Depends) { + t.Fatalf("len(parsed.Depends) = %d, want %d", len(parsed.Depends), len(original.Depends)) + } +} + +func TestParseTaskExport_EmptyInput(t *testing.T) { + _, err := ParseTaskExport(strings.NewReader("[]")) + if err != nil { + t.Fatalf("ParseTaskExport returned error for empty array: %v", err) + } +} + +func TestParseTaskExport_MultipleTasks(t *testing.T) { + data := `[{"uuid":"a1","description":"Task 1","status":"pending","priority":"H","tags":[],"urgency":10,"depends":[]},{"uuid":"b2","description":"Task 2","status":"completed","priority":"M","tags":["cli"],"urgency":5,"depends":["a1"]}]` + tasks, err := ParseTaskExport(strings.NewReader(data)) + if err != nil { + t.Fatalf("ParseTaskExport returned error: %v", err) + } + if len(tasks) != 2 { + t.Fatalf("len(tasks) = %d, want 2", len(tasks)) + } + if tasks[0].UUID != "a1" || tasks[1].UUID != "b2" { + t.Fatalf("unexpected task order or UUIDs: %v", tasks) + } +} + +func TestExtractUUIDFromOutput_NilOutput(t *testing.T) { + uuid := ExtractUUIDFromOutput("") + if uuid != "" { + t.Fatalf("ExtractUUIDFromOutput = %q, want empty string", uuid) + } +} + +func TestParseTaskExport_ReadError(t *testing.T) { + _, err := ParseTaskExport(&errReader{}) + if err == nil { + t.Fatal("expected error for read failure, got nil") + } +} + +type errReader struct{} + +func (r *errReader) Read(p []byte) (int, error) { + return 0, io.EOF +} |
