summaryrefslogtreecommitdiff
path: root/internal/task
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 09:07:07 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 09:07:07 +0300
commit02f5f1419c4cb5fccc47a0ce4dbf3957e73b0e79 (patch)
tree5ad976209e913bfcf8a022ea1d4c0d3cb0d45c60 /internal/task
parente2795b421a92ec16a64b1780dd71cb1a68b92680 (diff)
Add Taskwarrior shell promptv0.18.0
Add an in-app Taskwarrior command prompt for normal and ultra modes, including selected-task prefill, async completions, captured output, and non-interactive recurrence handling. Also preserve recurring-task delete/undo support in the staged changes.
Diffstat (limited to 'internal/task')
-rw-r--r--internal/task/task.go144
-rw-r--r--internal/task/task_test.go185
2 files changed, 313 insertions, 16 deletions
diff --git a/internal/task/task.go b/internal/task/task.go
index 4e6d787..4eaff0e 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -3,6 +3,7 @@ package task
import (
"bufio"
"bytes"
+ "context"
"encoding/json"
"fmt"
"io"
@@ -37,28 +38,33 @@ type Task struct {
Due string `json:"due"`
Priority string `json:"priority"`
Recur string `json:"recur"`
+ Parent string `json:"parent"`
+ RType string `json:"rtype"`
Urgency float64 `json:"urgency"`
Annotations []Annotation `json:"annotations"`
}
-func run(args ...string) error {
- if dbg.writer != nil {
- fmt.Fprintln(dbg.writer, "task "+strings.Join(args, " "))
- }
- cmd := exec.Command("task", args...)
+// RunResult contains the captured output from a task command invocation.
+type RunResult struct {
+ Args []string
+ Stdout string
+ Stderr string
+}
- // Capture stderr to provide better error messages
- var stderr bytes.Buffer
- cmd.Stderr = &stderr
+// CompletionSources contains values used for Taskwarrior shell completion.
+type CompletionSources struct {
+ Commands []string
+ Columns []string
+ Projects []string
+ Tags []string
+ IDs []string
+ UUIDs []string
+ UDAs []string
+}
- if err := cmd.Run(); err != nil {
- // Include stderr output in the error message
- if stderr.Len() > 0 {
- return fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String()))
- }
- return err
- }
- return nil
+func run(args ...string) error {
+ _, err := RunArgs(context.Background(), args)
+ return err
}
// modifyTask runs a modify command with validation
@@ -144,6 +150,103 @@ func AddLine(line string) error {
return AddArgs(fields)
}
+// RunLine splits line using shell-word rules and runs the resulting task
+// arguments. A leading "task" token is ignored so callers may accept either
+// "add foo" or "task add foo" from user input.
+func RunLine(ctx context.Context, line string) (RunResult, error) {
+ fields, err := shlex.Split(line)
+ if err != nil {
+ return RunResult{}, err
+ }
+ if len(fields) > 0 && fields[0] == "task" {
+ fields = fields[1:]
+ }
+ return RunArgs(ctx, fields)
+}
+
+// RunShellLine runs a user-entered task command in non-interactive mode. It
+// avoids Taskwarrior's recurring-task prompt by applying the same behavior as
+// answering "no": modify only the addressed recurrence.
+func RunShellLine(ctx context.Context, line string) (RunResult, error) {
+ fields, err := shlex.Split(line)
+ if err != nil {
+ return RunResult{}, err
+ }
+ if len(fields) > 0 && fields[0] == "task" {
+ fields = fields[1:]
+ }
+ fields = append([]string{"rc.recurrence.confirmation=no"}, fields...)
+ return RunArgs(ctx, fields)
+}
+
+// RunArgs runs "task" with args and captures stdout and stderr.
+func RunArgs(ctx context.Context, args []string) (RunResult, error) {
+ copied := append([]string(nil), args...)
+ result := RunResult{Args: copied}
+ if len(copied) == 0 {
+ return result, fmt.Errorf("empty task command")
+ }
+
+ if dbg.writer != nil {
+ fmt.Fprintln(dbg.writer, "task "+strings.Join(copied, " "))
+ }
+
+ cmd := exec.CommandContext(ctx, "task", copied...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+ result.Stdout = stdout.String()
+ result.Stderr = stderr.String()
+ if err != nil {
+ if strings.TrimSpace(result.Stderr) != "" {
+ return result, fmt.Errorf("%v: %s", err, strings.TrimSpace(result.Stderr))
+ }
+ return result, err
+ }
+ return result, nil
+}
+
+// LoadCompletionSources returns Taskwarrior-provided completion candidates.
+func LoadCompletionSources(ctx context.Context) CompletionSources {
+ return CompletionSources{
+ Commands: completionList(ctx, "_commands"),
+ Columns: completionList(ctx, "_columns"),
+ Projects: completionList(ctx, "_projects"),
+ Tags: completionList(ctx, "_tags"),
+ IDs: completionList(ctx, "_ids"),
+ UUIDs: completionList(ctx, "_uuids"),
+ UDAs: completionList(ctx, "_udas"),
+ }
+}
+
+func completionList(ctx context.Context, command string) []string {
+ result, err := RunArgs(ctx, []string{command})
+ if err != nil {
+ return nil
+ }
+ return outputLines(result.Stdout)
+}
+
+func outputLines(output string) []string {
+ scanner := bufio.NewScanner(strings.NewReader(output))
+ seen := make(map[string]struct{})
+ var lines []string
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ if _, ok := seen[line]; ok {
+ continue
+ }
+ seen[line] = struct{}{}
+ lines = append(lines, line)
+ }
+ return lines
+}
+
// Export retrieves all tasks using `task export rc.json.array=off` and parses
// the JSON output into a slice of Task structs.
// Export retrieves tasks using `task <filter> export rc.json.array=off` and parses
@@ -195,6 +298,15 @@ func SetStatusUUID(uuid, status string) error {
return run(uuid, "modify", "status:"+status)
}
+// RecurringSeries returns the recurring template and generated instances for
+// the recurring task identified by rootUUID.
+func RecurringSeries(rootUUID string) ([]Task, error) {
+ if strings.TrimSpace(rootUUID) == "" {
+ return nil, fmt.Errorf("empty recurring task UUID")
+ }
+ return Export(fmt.Sprintf("(%s or parent:%s)", rootUUID, rootUUID), "status.any:")
+}
+
// Start begins the task with the given id.
func Start(id int) error {
return simpleTaskCommand(id, "start")
diff --git a/internal/task/task_test.go b/internal/task/task_test.go
index 2869260..838aa3a 100644
--- a/internal/task/task_test.go
+++ b/internal/task/task_test.go
@@ -1,6 +1,7 @@
package task
import (
+ "context"
"fmt"
"os"
"os/exec"
@@ -117,6 +118,190 @@ func TestAddAndExport(t *testing.T) {
}
}
+func TestRunLineSplitsCapturesAndStripsTaskPrefix(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ argsFile := filepath.Join(tmp, "args.txt")
+
+ script := "#!/bin/sh\n" +
+ "printf '%s\\n' \"$@\" > " + argsFile + "\n" +
+ "echo stdout-value\n" +
+ "echo stderr-value >&2\n"
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ result, err := RunLine(context.Background(), `task add "hello world" project:home`)
+ if err != nil {
+ t.Fatalf("RunLine: %v", err)
+ }
+ if result.Stdout != "stdout-value\n" {
+ t.Fatalf("stdout = %q", result.Stdout)
+ }
+ if result.Stderr != "stderr-value\n" {
+ t.Fatalf("stderr = %q", result.Stderr)
+ }
+
+ data, err := os.ReadFile(argsFile)
+ if err != nil {
+ t.Fatalf("read args: %v", err)
+ }
+ got := strings.Split(strings.TrimSpace(string(data)), "\n")
+ want := []string{"add", "hello world", "project:home"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Fatalf("args = %#v, want %#v", got, want)
+ }
+}
+
+func TestRunShellLineDisablesRecurrencePrompt(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ argsFile := filepath.Join(tmp, "args.txt")
+
+ script := "#!/bin/sh\n" +
+ "printf '%s\\n' \"$@\" > " + argsFile + "\n"
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ if _, err := RunShellLine(context.Background(), `task 260 modify project:foo`); err != nil {
+ t.Fatalf("RunShellLine: %v", err)
+ }
+
+ data, err := os.ReadFile(argsFile)
+ if err != nil {
+ t.Fatalf("read args: %v", err)
+ }
+ got := strings.Split(strings.TrimSpace(string(data)), "\n")
+ want := []string{"rc.recurrence.confirmation=no", "260", "modify", "project:foo"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Fatalf("args = %#v, want %#v", got, want)
+ }
+}
+
+func TestRunLineReturnsCapturedErrorOutput(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ script := "#!/bin/sh\n" +
+ "echo bad-output >&2\n" +
+ "exit 2\n"
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ result, err := RunLine(context.Background(), "bad command")
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+ if result.Stderr != "bad-output\n" {
+ t.Fatalf("stderr = %q", result.Stderr)
+ }
+ if !strings.Contains(err.Error(), "bad-output") {
+ t.Fatalf("error did not include stderr: %v", err)
+ }
+}
+
+func TestLoadCompletionSources(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ script := "#!/bin/sh\n" +
+ "case \"$1\" in\n" +
+ " _commands) printf 'add\\nmodify\\n' ;;\n" +
+ " _columns) printf 'project\\ndue\\n' ;;\n" +
+ " _projects) printf 'home\\nwork\\n' ;;\n" +
+ " _tags) printf 'urgent\\n' ;;\n" +
+ " _ids) printf '1\\n2\\n' ;;\n" +
+ " _uuids) printf 'uuid-1\\n' ;;\n" +
+ " _udas) printf 'custom\\n' ;;\n" +
+ "esac\n"
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ sources := LoadCompletionSources(context.Background())
+ if strings.Join(sources.Commands, ",") != "add,modify" {
+ t.Fatalf("commands = %#v", sources.Commands)
+ }
+ if strings.Join(sources.Columns, ",") != "project,due" {
+ t.Fatalf("columns = %#v", sources.Columns)
+ }
+ if strings.Join(sources.Projects, ",") != "home,work" {
+ t.Fatalf("projects = %#v", sources.Projects)
+ }
+ if strings.Join(sources.Tags, ",") != "urgent" {
+ t.Fatalf("tags = %#v", sources.Tags)
+ }
+ if strings.Join(sources.IDs, ",") != "1,2" {
+ t.Fatalf("ids = %#v", sources.IDs)
+ }
+ if strings.Join(sources.UUIDs, ",") != "uuid-1" {
+ t.Fatalf("uuids = %#v", sources.UUIDs)
+ }
+ if strings.Join(sources.UDAs, ",") != "custom" {
+ t.Fatalf("udas = %#v", sources.UDAs)
+ }
+}
+
+func TestRecurringSeries(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ argsFile := filepath.Join(tmp, "args.txt")
+
+ script := "#!/bin/sh\n" +
+ "echo \"$@\" > " + argsFile + "\n" +
+ "if [ \"$1\" = \"(parent-uuid or parent:parent-uuid)\" ] && [ \"$2\" = \"status.any:\" ] && [ \"$3\" = \"export\" ]; then\n" +
+ " echo '{\"id\":0,\"uuid\":\"parent-uuid\",\"description\":\"template\",\"status\":\"recurring\",\"recur\":\"daily\"}'\n" +
+ " echo '{\"id\":1,\"uuid\":\"child-uuid\",\"parent\":\"parent-uuid\",\"description\":\"child\",\"status\":\"pending\",\"recur\":\"daily\"}'\n" +
+ " exit 0\n" +
+ "fi\n" +
+ "exit 1\n"
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ tasks, err := RecurringSeries("parent-uuid")
+ if err != nil {
+ t.Fatalf("RecurringSeries: %v", err)
+ }
+ if len(tasks) != 2 {
+ t.Fatalf("expected 2 tasks, got %d", len(tasks))
+ }
+ if tasks[0].UUID != "parent-uuid" || tasks[0].Status != "recurring" {
+ t.Fatalf("unexpected template task: %#v", tasks[0])
+ }
+ if tasks[1].UUID != "child-uuid" || tasks[1].Parent != "parent-uuid" {
+ t.Fatalf("unexpected child task: %#v", tasks[1])
+ }
+
+ data, err := os.ReadFile(argsFile)
+ if err != nil {
+ t.Fatalf("read args: %v", err)
+ }
+ if got := strings.TrimSpace(string(data)); got != "(parent-uuid or parent:parent-uuid) status.any: export rc.json.array=off" {
+ t.Fatalf("unexpected args: %q", got)
+ }
+}
+
func TestModifyHelpers(t *testing.T) {
if _, err := exec.LookPath("task"); err != nil {
t.Skip("task command not available")