diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-25 17:38:03 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-25 17:38:03 +0300 |
| commit | 6ba890330df991eb12cd313a42cf2704f3c30227 (patch) | |
| tree | 52f0c28ed49bbb74a535822001acb2e6914e1f65 | |
| parent | c8c3307e9e636406491572d9b8dda3a84929613d (diff) | |
Refactor task package layout for 4r0
| -rw-r--r-- | internal/task/crud.go (renamed from internal/task/task.go) | 300 | ||||
| -rw-r--r-- | internal/task/debug.go | 42 | ||||
| -rw-r--r-- | internal/task/exec.go | 84 | ||||
| -rw-r--r-- | internal/task/export.go | 102 | ||||
| -rw-r--r-- | internal/task/types.go | 48 |
5 files changed, 300 insertions, 276 deletions
diff --git a/internal/task/task.go b/internal/task/crud.go index dc02d48..51dd13c 100644 --- a/internal/task/task.go +++ b/internal/task/crud.go @@ -1,138 +1,17 @@ package task import ( - "bufio" - "bytes" "context" - "encoding/json" "fmt" - "io" "os" "os/exec" "sort" "strconv" - "strings" "time" "github.com/google/shlex" ) -// DateFormat is the date format used by Taskwarrior in all date fields -// (e.g. Entry, Due, Start). All date parsing and formatting in this -// package uses this constant. -const DateFormat = "20060102T150405Z" - -// Task represents a taskwarrior task as returned by `task export`. -type Annotation struct { - Entry string `json:"entry"` - Description string `json:"description"` -} - -type Task struct { - ID int `json:"id"` - UUID string `json:"uuid"` - Description string `json:"description"` - Project string `json:"project"` - Tags []string `json:"tags"` - Status string `json:"status"` - Start string `json:"start"` - Entry string `json:"entry"` - 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"` -} - -// RunResult contains the captured output from a task command invocation. -type RunResult struct { - Args []string - Stdout string - Stderr string -} - -// 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 -} - -func run(args ...string) error { - return runContext(context.Background(), args...) -} - -func runContext(ctx context.Context, args ...string) error { - _, err := RunArgs(ctx, args) - return err -} - -// modifyTask runs a modify command with validation -func modifyTask(id int, args ...string) error { - return modifyTaskContext(context.Background(), id, args...) -} - -func modifyTaskContext(ctx context.Context, id int, args ...string) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return runContext(ctx, append([]string{strconv.Itoa(id), "modify"}, args...)...) -} - -// simpleTaskCommand runs a simple command on a task with validation -func simpleTaskCommand(id int, command string) error { - return simpleTaskCommandContext(context.Background(), id, command) -} - -func simpleTaskCommandContext(ctx context.Context, id int, command string) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return runContext(ctx, strconv.Itoa(id), command) -} - -// debugConfig groups the optional debug-logging state for the task package. -// Collecting related vars into a struct makes the mutable state explicit and -// allows the logger to be swapped or reset cleanly without touching unrelated -// package globals. -type debugConfig struct { - writer io.Writer - file *os.File // tracked separately so it can be closed on reconfiguration -} - -// dbg holds the active debug-logging configuration for this package. -// It is written only via SetDebugLog and read only in run(). -var dbg debugConfig - -// SetDebugLog enables logging of executed commands to the given file. -// Passing an empty path disables logging and closes any previously opened file. -func SetDebugLog(path string) error { - // Close existing debug file if open before re-configuring. - if dbg.file != nil { - _ = dbg.file.Close() - dbg.file = nil - dbg.writer = nil - } - - if path == "" { - return nil - } - - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return err - } - dbg.file = f - dbg.writer = f - return nil -} - // Add creates a new task with the given description and tags. func Add(description string, tags []string) error { return AddContext(context.Background(), description, tags) @@ -183,152 +62,6 @@ func AddLineContext(ctx context.Context, line string) error { return AddArgsContext(ctx, 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 { - if _, err := fmt.Fprintln(dbg.writer, "task "+strings.Join(copied, " ")); err != nil { - return result, fmt.Errorf("write debug log: %w", err) - } - } - - cmd := exec.CommandContext(ctx, "task", copied...) - configureCommandContext(cmd) - 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 ctxErr := ctx.Err(); ctxErr != nil { - return result, fmt.Errorf("task command: %w", ctxErr) - } - if strings.TrimSpace(result.Stderr) != "" { - return result, fmt.Errorf("%w: %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 tasks using `task <filter> export rc.json.array=off` and parses -// the JSON output into a slice of Task structs. Optional filter arguments are -// passed directly to the `task` command before `export`. -func Export(ctx context.Context, filters ...string) ([]Task, error) { - args := append(filters, "export", "rc.json.array=off") - cmd := exec.CommandContext(ctx, "task", args...) - configureCommandContext(cmd) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - out, err := cmd.Output() - if err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, fmt.Errorf("task export: %w", ctxErr) - } - // Include stderr output in the error message - if stderr.Len() > 0 { - return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) - } - return nil, err - } - - var tasks []Task - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Bytes() - line = bytes.TrimSpace(line) - if len(line) == 0 { - continue - } - var t Task - if err := json.Unmarshal(line, &t); err != nil { - return nil, err - } - tasks = append(tasks, t) - } - if err := scanner.Err(); err != nil { - return nil, err - } - return tasks, nil -} - // SetStatus changes the status of the task with the given id. func SetStatus(id int, status string) error { return SetStatusContext(context.Background(), id, status) @@ -351,15 +84,6 @@ func SetStatusUUIDContext(ctx context.Context, uuid, status string) error { return runContext(ctx, uuid, "modify", "status:"+status) } -// RecurringSeries returns the recurring template and generated instances for -// the recurring task identified by rootUUID. -func RecurringSeries(ctx context.Context, rootUUID string) ([]Task, error) { - if strings.TrimSpace(rootUUID) == "" { - return nil, fmt.Errorf("empty recurring task UUID") - } - return Export(ctx, fmt.Sprintf("(%s or parent:%s)", rootUUID, rootUUID), "status.any:") -} - // Start begins the task with the given id. func Start(id int) error { return StartContext(context.Background(), id) @@ -689,3 +413,27 @@ func Edit(id int) error { } return EditCmd(id).Run() } + +// modifyTask runs a modify command with validation +func modifyTask(id int, args ...string) error { + return modifyTaskContext(context.Background(), id, args...) +} + +func modifyTaskContext(ctx context.Context, id int, args ...string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } + return runContext(ctx, append([]string{strconv.Itoa(id), "modify"}, args...)...) +} + +// simpleTaskCommand runs a simple command on a task with validation +func simpleTaskCommand(id int, command string) error { + return simpleTaskCommandContext(context.Background(), id, command) +} + +func simpleTaskCommandContext(ctx context.Context, id int, command string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } + return runContext(ctx, strconv.Itoa(id), command) +} diff --git a/internal/task/debug.go b/internal/task/debug.go new file mode 100644 index 0000000..061dac2 --- /dev/null +++ b/internal/task/debug.go @@ -0,0 +1,42 @@ +package task + +import ( + "io" + "os" +) + +// debugConfig groups the optional debug-logging state for the task package. +// Collecting related vars into a struct makes the mutable state explicit and +// allows the logger to be swapped or reset cleanly without touching unrelated +// package globals. +type debugConfig struct { + writer io.Writer + file *os.File // tracked separately so it can be closed on reconfiguration +} + +// dbg holds the active debug-logging configuration for this package. +// It is written only via SetDebugLog and read only in run(). +var dbg debugConfig + +// SetDebugLog enables logging of executed commands to the given file. +// Passing an empty path disables logging and closes any previously opened file. +func SetDebugLog(path string) error { + // Close existing debug file if open before re-configuring. + if dbg.file != nil { + _ = dbg.file.Close() + dbg.file = nil + dbg.writer = nil + } + + if path == "" { + return nil + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + dbg.file = f + dbg.writer = f + return nil +} diff --git a/internal/task/exec.go b/internal/task/exec.go new file mode 100644 index 0000000..9ef84f2 --- /dev/null +++ b/internal/task/exec.go @@ -0,0 +1,84 @@ +package task + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/google/shlex" +) + +func run(args ...string) error { + return runContext(context.Background(), args...) +} + +func runContext(ctx context.Context, args ...string) error { + _, err := RunArgs(ctx, args) + return err +} + +// 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 { + if _, err := fmt.Fprintln(dbg.writer, "task "+strings.Join(copied, " ")); err != nil { + return result, fmt.Errorf("write debug log: %w", err) + } + } + + cmd := exec.CommandContext(ctx, "task", copied...) + configureCommandContext(cmd) + 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 ctxErr := ctx.Err(); ctxErr != nil { + return result, fmt.Errorf("task command: %w", ctxErr) + } + if strings.TrimSpace(result.Stderr) != "" { + return result, fmt.Errorf("%w: %s", err, strings.TrimSpace(result.Stderr)) + } + return result, err + } + return result, nil +} diff --git a/internal/task/export.go b/internal/task/export.go new file mode 100644 index 0000000..7759794 --- /dev/null +++ b/internal/task/export.go @@ -0,0 +1,102 @@ +package task + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// 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 tasks using `task <filter> export rc.json.array=off` and parses +// the JSON output into a slice of Task structs. Optional filter arguments are +// passed directly to the `task` command before `export`. +func Export(ctx context.Context, filters ...string) ([]Task, error) { + args := append(filters, "export", "rc.json.array=off") + cmd := exec.CommandContext(ctx, "task", args...) + configureCommandContext(cmd) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + out, err := cmd.Output() + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("task export: %w", ctxErr) + } + // Include stderr output in the error message + if stderr.Len() > 0 { + return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return nil, err + } + + var tasks []Task + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Bytes() + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + var t Task + if err := json.Unmarshal(line, &t); err != nil { + return nil, err + } + tasks = append(tasks, t) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return tasks, nil +} + +// RecurringSeries returns the recurring template and generated instances for +// the recurring task identified by rootUUID. +func RecurringSeries(ctx context.Context, rootUUID string) ([]Task, error) { + if strings.TrimSpace(rootUUID) == "" { + return nil, fmt.Errorf("empty recurring task UUID") + } + return Export(ctx, fmt.Sprintf("(%s or parent:%s)", rootUUID, rootUUID), "status.any:") +} diff --git a/internal/task/types.go b/internal/task/types.go new file mode 100644 index 0000000..17b6e06 --- /dev/null +++ b/internal/task/types.go @@ -0,0 +1,48 @@ +package task + +// DateFormat is the date format used by Taskwarrior in all date fields +// (e.g. Entry, Due, Start). All date parsing and formatting in this +// package uses this constant. +const DateFormat = "20060102T150405Z" + +// Task represents a taskwarrior task as returned by `task export`. +type Annotation struct { + Entry string `json:"entry"` + Description string `json:"description"` +} + +type Task struct { + ID int `json:"id"` + UUID string `json:"uuid"` + Description string `json:"description"` + Project string `json:"project"` + Tags []string `json:"tags"` + Status string `json:"status"` + Start string `json:"start"` + Entry string `json:"entry"` + 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"` +} + +// RunResult contains the captured output from a task command invocation. +type RunResult struct { + Args []string + Stdout string + Stderr string +} + +// 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 +} |
