summaryrefslogtreecommitdiff
path: root/internal/askcli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/askcli')
-rw-r--r--internal/askcli/command_info_add_test.go12
-rw-r--r--internal/askcli/command_start_deps.go44
-rw-r--r--internal/askcli/command_start_deps_test.go157
-rw-r--r--internal/askcli/command_write.go24
-rw-r--r--internal/askcli/formatter.go10
-rw-r--r--internal/askcli/formatter_test.go5
6 files changed, 233 insertions, 19 deletions
diff --git a/internal/askcli/command_info_add_test.go b/internal/askcli/command_info_add_test.go
index 0e5b366..b6f0bd0 100644
--- a/internal/askcli/command_info_add_test.go
+++ b/internal/askcli/command_info_add_test.go
@@ -57,9 +57,12 @@ func TestHandleInfo_Success(t *testing.T) {
if !strings.Contains(output, "Started: no") {
t.Fatalf("output missing explicit started state: %s", output)
}
- if !strings.Contains(output, "Depends: 1 (dep-1)") {
+ if !strings.Contains(output, "Depends: 1") {
t.Fatalf("output missing formatted dependency alias: %s", output)
}
+ if strings.Contains(output, "dep-1") {
+ t.Fatalf("output should not list dependency UUID when alias exists: %s", output)
+ }
}
func TestHandleInfo_Success_DebugShowsUUID(t *testing.T) {
@@ -135,11 +138,12 @@ func TestHandleInfo_AssignsDependencyAliasesFromInfo(t *testing.T) {
}
output := stdout.String()
- if !strings.Contains(output, "Depends:") ||
- !strings.Contains(output, "(dep-a)") ||
- !strings.Contains(output, "(dep-b)") {
+ if !strings.Contains(output, "Depends: 1, 2") {
t.Fatalf("output missing assigned dependency aliases: %s", output)
}
+ if strings.Contains(output, "dep-a") || strings.Contains(output, "dep-b") {
+ t.Fatalf("output should not list dependency UUIDs when aliases exist: %s", output)
+ }
}
func TestHandleInfo_AliasSelector(t *testing.T) {
diff --git a/internal/askcli/command_start_deps.go b/internal/askcli/command_start_deps.go
new file mode 100644
index 0000000..94a6a9e
--- /dev/null
+++ b/internal/askcli/command_start_deps.go
@@ -0,0 +1,44 @@
+package askcli
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// verifyDependenciesCompletedForStart returns 0 if every dependency is completed,
+// or 1 after writing an error to stderr when the task must not be started yet.
+func (d *Dispatcher) verifyDependenciesCompletedForStart(ctx context.Context, task TaskExport, stderr io.Writer) int {
+ if len(task.Depends) == 0 {
+ return 0
+ }
+ aliases, err := ensureTaskAliasesForUUIDs(task.Depends)
+ if err != nil {
+ fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err)
+ return 1
+ }
+ var incomplete []string
+ for _, depUUID := range task.Depends {
+ depTasks, code, err := d.exportTasks(ctx, []string{"uuid:" + depUUID, "export"}, stderr)
+ if err != nil {
+ writeInfoError(stderr, err)
+ return code
+ }
+ if len(depTasks) == 0 {
+ fmt.Fprintf(stderr, "error: dependency task not found (%s)\n", displayTaskAlias(depUUID, aliases))
+ return 1
+ }
+ status := strings.ToLower(strings.TrimSpace(depTasks[0].Status))
+ if status != "completed" {
+ label := displayTaskAlias(depUUID, aliases)
+ incomplete = append(incomplete, fmt.Sprintf("%s (%s)", label, depTasks[0].Status))
+ }
+ }
+ if len(incomplete) > 0 {
+ fmt.Fprintf(stderr, "error: cannot start until all dependencies are completed; incomplete: %s\n",
+ strings.Join(incomplete, ", "))
+ return 1
+ }
+ return 0
+}
diff --git a/internal/askcli/command_start_deps_test.go b/internal/askcli/command_start_deps_test.go
new file mode 100644
index 0000000..9ba197b
--- /dev/null
+++ b/internal/askcli/command_start_deps_test.go
@@ -0,0 +1,157 @@
+package askcli
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+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
+ }()
+
+ writeTaskAliasCacheForTest(t, taskAliasCache{
+ NextID: 2,
+ Entries: []taskAliasCacheEntry{
+ {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow},
+ {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow},
+ },
+ })
+
+ var startCalls int
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ switch {
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"main-uuid","description":"Main","status":"pending","priority":"M","tags":[],"urgency":0,"depends":["dep-uuid"]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:dep-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"dep-uuid","description":"Dep","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "start":
+ startCalls++
+ return 0, nil
+ default:
+ t.Fatalf("unexpected runner args: %v", args)
+ return 1, nil
+ }
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr)
+ if code != 1 {
+ t.Fatalf("start code = %d, want 1", code)
+ }
+ if startCalls != 0 {
+ t.Fatalf("task start ran %d times, want 0", startCalls)
+ }
+ if !strings.Contains(stderr.String(), "cannot start until all dependencies are completed") {
+ t.Fatalf("stderr = %q, want dependency gate message", stderr.String())
+ }
+ if !strings.Contains(stderr.String(), "1 (pending)") {
+ t.Fatalf("stderr should name incomplete dependency with alias and status: %q", stderr.String())
+ }
+}
+
+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
+ }()
+
+ writeTaskAliasCacheForTest(t, taskAliasCache{
+ NextID: 2,
+ Entries: []taskAliasCacheEntry{
+ {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow},
+ {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow},
+ },
+ })
+
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ switch {
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"main-uuid","description":"Main","status":"pending","priority":"M","tags":[],"urgency":0,"depends":["dep-uuid"]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:dep-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"dep-uuid","description":"Dep","status":"completed","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "start":
+ return 0, nil
+ default:
+ t.Fatalf("unexpected runner args: %v", args)
+ return 1, nil
+ }
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("start code = %d stderr=%q", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "ok 0") {
+ t.Fatalf("stdout = %q, want ok + alias", stdout.String())
+ }
+}
+
+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
+ }()
+
+ writeTaskAliasCacheForTest(t, taskAliasCache{
+ NextID: 2,
+ Entries: []taskAliasCacheEntry{
+ {UUID: "main-uuid", Alias: "0", CreatedAt: fixedNow},
+ {UUID: "dep-uuid", Alias: "1", CreatedAt: fixedNow},
+ },
+ })
+
+ d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
+ switch {
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"main-uuid","description":"Main","status":"pending","priority":"M","tags":[],"urgency":0,"depends":["dep-uuid"]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:dep-uuid" && args[1] == "export":
+ _, _ = io.WriteString(stdout, `[{"uuid":"dep-uuid","description":"Dep","status":"COMPLETED","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
+ return 0, nil
+ case len(args) == 2 && args[0] == "uuid:main-uuid" && args[1] == "start":
+ return 0, nil
+ default:
+ t.Fatalf("unexpected runner args: %v", args)
+ return 1, nil
+ }
+ }})
+
+ var stdout, stderr bytes.Buffer
+ code, _ := d.Dispatch(context.Background(), []string{"start", "main-uuid"}, &bytes.Buffer{}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("start code = %d stderr=%q", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "ok 0") {
+ t.Fatalf("stdout = %q, want ok + alias", stdout.String())
+ }
+}
diff --git a/internal/askcli/command_write.go b/internal/askcli/command_write.go
index d8dbf5b..6887ff7 100644
--- a/internal/askcli/command_write.go
+++ b/internal/askcli/command_write.go
@@ -67,11 +67,25 @@ func (d *Dispatcher) handleStart(ctx context.Context, args []string, stdout, std
_, _ = io.WriteString(stderr, "error: ask start requires an ID or UUID argument\n")
return 1, nil
}
- return d.runSingleTaskCommand(ctx, args[1], stdout, stderr, func(resolved resolvedTaskSelector) []string {
- // uuid:<uuid> is used as the filter so taskwarrior selects the exact task;
- // the action verb follows the filter.
- return []string{"uuid:" + resolved.UUID, "start"}
- })
+ resolved, tasks, code, err := d.resolveTaskSelector(ctx, args[1], stderr)
+ if err != nil {
+ writeInfoError(stderr, err)
+ return code, nil
+ }
+ if code != 0 {
+ return code, nil
+ }
+ if depCode := d.verifyDependenciesCompletedForStart(ctx, tasks[0], stderr); depCode != 0 {
+ return depCode, nil
+ }
+
+ var outBuf bytes.Buffer
+ code, err = d.runner.Run(ctx, []string{"uuid:" + resolved.UUID, "start"}, nil, &outBuf, io.Discard)
+ if code != 0 {
+ return code, err
+ }
+ _, _ = io.WriteString(stdout, FormatSuccess(displayResolvedTaskID(resolved)))
+ return 0, nil
}
func (d *Dispatcher) handleStop(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) {
diff --git a/internal/askcli/formatter.go b/internal/askcli/formatter.go
index 7f9b846..8d62125 100644
--- a/internal/askcli/formatter.go
+++ b/internal/askcli/formatter.go
@@ -192,16 +192,8 @@ func FormatError(err error, taskID string) string {
func formatTaskDependencies(depends []string, aliases map[string]string) string {
items := make([]string, 0, len(depends))
for _, uuid := range depends {
- items = append(items, formatTaskReference(uuid, aliases))
+ items = append(items, displayTaskAlias(uuid, aliases))
}
slices.Sort(items)
return strings.Join(items, ", ")
}
-
-func formatTaskReference(uuid string, aliases map[string]string) string {
- alias := strings.TrimSpace(aliases[uuid])
- if alias == "" || alias == uuid {
- return uuid
- }
- return fmt.Sprintf("%s (%s)", alias, uuid)
-}
diff --git a/internal/askcli/formatter_test.go b/internal/askcli/formatter_test.go
index e473ed4..9788430 100644
--- a/internal/askcli/formatter_test.go
+++ b/internal/askcli/formatter_test.go
@@ -195,9 +195,12 @@ func TestFormatTaskInfo(t *testing.T) {
if !strings.Contains(output, "cli, agent") {
t.Fatalf("FormatTaskInfo missing tags: %s", output)
}
- if !strings.Contains(output, "1 (dep-1)") || !strings.Contains(output, "2 (dep-2)") {
+ if !strings.Contains(output, "Depends: 1, 2") {
t.Fatalf("FormatTaskInfo missing formatted depends: %s", output)
}
+ if strings.Contains(output, "dep-1") || strings.Contains(output, "dep-2") {
+ t.Fatalf("FormatTaskInfo should not list dependency UUIDs when aliases exist: %s", output)
+ }
if !strings.Contains(output, "First note") {
t.Fatalf("FormatTaskInfo missing annotation: %s", output)
}