diff options
Diffstat (limited to 'internal/task')
| -rw-r--r-- | internal/task/task.go | 144 | ||||
| -rw-r--r-- | internal/task/task_test.go | 185 |
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") |
