Detailed changes
@@ -73,11 +73,12 @@ func runAdd(cmd *cobra.Command, args []string) error {
builder := apiClient.NewTask(name)
- if err := applyAreaAndGoal(cmd, builder); err != nil {
+ area, err := applyAreaAndGoal(cmd, builder)
+ if err != nil {
return err
}
- if err := applyOptionalFlags(cmd, builder); err != nil {
+ if err := applyOptionalFlags(cmd, builder, area); err != nil {
return err
}
@@ -110,54 +111,66 @@ func resolveName(arg string) (string, error) {
return "", ErrNoInput
}
-func applyAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskBuilder) error {
+//nolint:nilnil // nil area with no error is valid when area flag not provided
+func applyAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskBuilder) (*config.Area, error) {
areaKey, _ := cmd.Flags().GetString("area")
goalKey, _ := cmd.Flags().GetString("goal")
if areaKey == "" && goalKey == "" {
- return nil
+ return nil, nil
}
if areaKey == "" && goalKey != "" {
fmt.Fprintln(cmd.ErrOrStderr(), ui.Warning.Render("Goal specified without area; ignoring"))
- return nil
+ return nil, nil
}
cfg, err := config.Load()
if err != nil {
- return err
+ return nil, err
}
area := cfg.AreaByKey(areaKey)
if area == nil {
- return fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
+ return nil, fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
}
builder.InArea(area.ID)
if goalKey == "" {
- return nil
+ return area, nil
}
goal := area.GoalByKey(goalKey)
if goal == nil {
- return fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
+ return nil, fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
}
builder.InGoal(goal.ID)
- return nil
+ return area, nil
}
-func applyOptionalFlags(cmd *cobra.Command, builder *lunatask.TaskBuilder) error {
+//nolint:cyclop // flag handling inherently has many branches
+func applyOptionalFlags(cmd *cobra.Command, builder *lunatask.TaskBuilder, area *config.Area) error {
if status, _ := cmd.Flags().GetString("status"); status != "" {
- s, err := validate.TaskStatus(status)
+ var (
+ parsedStatus lunatask.TaskStatus
+ err error
+ )
+
+ if area != nil {
+ parsedStatus, err = validate.TaskStatusForWorkflow(status, area.Workflow)
+ } else {
+ parsedStatus, err = validate.TaskStatus(status)
+ }
+
if err != nil {
return err
}
- builder.WithStatus(s)
+ builder.WithStatus(parsedStatus)
}
if note, _ := cmd.Flags().GetString("note"); note != "" {
@@ -68,11 +68,12 @@ func runUpdate(cmd *cobra.Command, args []string) error {
return err
}
- if err := applyUpdateAreaAndGoal(cmd, builder); err != nil {
+ area, err := applyUpdateAreaAndGoal(cmd, builder)
+ if err != nil {
return err
}
- if err := applyUpdateFlags(cmd, builder); err != nil {
+ if err := applyUpdateFlags(cmd, builder, area); err != nil {
return err
}
@@ -104,54 +105,66 @@ func applyUpdateName(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) er
return nil
}
-func applyUpdateAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) error {
+//nolint:nilnil // nil area with no error is valid when area flag not provided
+func applyUpdateAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) (*config.Area, error) {
areaKey, _ := cmd.Flags().GetString("area")
goalKey, _ := cmd.Flags().GetString("goal")
if areaKey == "" && goalKey == "" {
- return nil
+ return nil, nil
}
if areaKey == "" && goalKey != "" {
fmt.Fprintln(cmd.ErrOrStderr(), ui.Warning.Render("Goal specified without area; ignoring"))
- return nil
+ return nil, nil
}
cfg, err := config.Load()
if err != nil {
- return err
+ return nil, err
}
area := cfg.AreaByKey(areaKey)
if area == nil {
- return fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
+ return nil, fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
}
builder.InArea(area.ID)
if goalKey == "" {
- return nil
+ return area, nil
}
goal := area.GoalByKey(goalKey)
if goal == nil {
- return fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
+ return nil, fmt.Errorf("%w: %s", ErrUnknownGoal, goalKey)
}
builder.InGoal(goal.ID)
- return nil
+ return area, nil
}
-func applyUpdateFlags(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) error {
+//nolint:cyclop // flag handling inherently has many branches
+func applyUpdateFlags(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder, area *config.Area) error {
if status, _ := cmd.Flags().GetString("status"); status != "" {
- s, err := validate.TaskStatus(status)
+ var (
+ parsedStatus lunatask.TaskStatus
+ err error
+ )
+
+ if area != nil {
+ parsedStatus, err = validate.TaskStatusForWorkflow(status, area.Workflow)
+ } else {
+ parsedStatus, err = validate.TaskStatus(status)
+ }
+
if err != nil {
return err
}
- builder.WithStatus(s)
+ builder.WithStatus(parsedStatus)
}
if note, _ := cmd.Flags().GetString("note"); note != "" {
@@ -7,7 +7,10 @@ package shared
import (
"errors"
"fmt"
+ "slices"
+ "strings"
+ "git.secluded.site/go-lunatask"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -32,6 +35,9 @@ const (
// ErrInvalidEstimate indicates the estimate is out of range.
var ErrInvalidEstimate = errors.New("estimate must be between 0 and 720 minutes")
+// ErrInvalidStatusForWorkflow indicates the status is not valid for the area's workflow.
+var ErrInvalidStatusForWorkflow = errors.New("invalid status for workflow")
+
// ValidateEstimate checks that an estimate is within the valid range (0-720 minutes).
func ValidateEstimate(estimate int) error {
if estimate < MinEstimate || estimate > MaxEstimate {
@@ -40,3 +46,51 @@ func ValidateEstimate(estimate int) error {
return nil
}
+
+// ValidateStatusForWorkflow parses a status string and validates it's allowed for
+// the given workflow. Returns the parsed status and nil on success, or an error
+// with valid options on failure.
+func ValidateStatusForWorkflow(
+ input string,
+ workflow lunatask.Workflow,
+) (lunatask.TaskStatus, error) {
+ status, err := lunatask.ParseTaskStatus(input)
+ if err != nil {
+ return "", fmt.Errorf(
+ "%w: '%s' for %s; valid: %s",
+ ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+ )
+ }
+
+ if !slices.Contains(workflow.ValidStatuses(), status) {
+ return "", fmt.Errorf(
+ "%w: '%s' not valid for %s; valid: %s",
+ ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+ )
+ }
+
+ return status, nil
+}
+
+func formatValidStatuses(workflow lunatask.Workflow) string {
+ statuses := workflow.ValidStatuses()
+ strs := make([]string, len(statuses))
+
+ for i, s := range statuses {
+ strs[i] = string(s)
+ }
+
+ return strings.Join(strs, ", ")
+}
+
+// FormatAllStatuses returns a comma-separated list of all valid task statuses.
+func FormatAllStatuses() string {
+ statuses := lunatask.AllTaskStatuses()
+ strs := make([]string, len(statuses))
+
+ for i, s := range statuses {
+ strs[i] = string(s)
+ }
+
+ return strings.Join(strs, ", ")
+}
@@ -232,7 +232,12 @@ func (h *Handler) parseTaskCreateInput(input CreateInput) (*parsedTaskCreateInpu
}
if input.Status != nil {
- status, err := lunatask.ParseTaskStatus(*input.Status)
+ area := h.cfg.AreaByID(parsed.AreaID)
+ if area == nil {
+ return nil, shared.ErrorResult("internal error: area not found after validation")
+ }
+
+ status, err := shared.ValidateStatusForWorkflow(*input.Status, area.Workflow)
if err != nil {
return nil, shared.ErrorResult(err.Error())
}
@@ -215,7 +215,7 @@ func (h *Handler) listTasks(
if input.Status != nil {
if _, err := lunatask.ParseTaskStatus(*input.Status); err != nil {
- return shared.ErrorResult("invalid status: must be later, next, in-progress, waiting, or completed"),
+ return shared.ErrorResult(fmt.Sprintf("invalid status '%s'; valid: %s", *input.Status, shared.FormatAllStatuses())),
QueryOutput{Entity: EntityTask}, nil
}
}
@@ -6,6 +6,7 @@ package crud
import (
"context"
+ "fmt"
"git.secluded.site/go-lunatask"
"git.secluded.site/lune/internal/dateutil"
@@ -143,7 +144,7 @@ func (h *Handler) updateTask(
}, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil
}
-//nolint:cyclop,funlen
+//nolint:cyclop,funlen,gocognit,nestif
func (h *Handler) parseTaskUpdateInput(input UpdateInput) (*parsedTaskUpdateInput, *mcp.CallToolResult) {
_, id, err := lunatask.ParseReference(input.ID)
if err != nil {
@@ -189,12 +190,26 @@ func (h *Handler) parseTaskUpdateInput(input UpdateInput) (*parsedTaskUpdateInpu
}
if input.Status != nil {
- status, err := lunatask.ParseTaskStatus(*input.Status)
- if err != nil {
- return nil, shared.ErrorResult(err.Error())
+ if parsed.AreaID != nil {
+ area := h.cfg.AreaByID(*parsed.AreaID)
+ if area != nil {
+ status, err := shared.ValidateStatusForWorkflow(*input.Status, area.Workflow)
+ if err != nil {
+ return nil, shared.ErrorResult(err.Error())
+ }
+
+ parsed.Status = &status
+ }
+ } else {
+ status, err := lunatask.ParseTaskStatus(*input.Status)
+ if err != nil {
+ return nil, shared.ErrorResult(
+ fmt.Sprintf("invalid status '%s'; valid: %s", *input.Status, shared.FormatAllStatuses()),
+ )
+ }
+
+ parsed.Status = &status
}
-
- parsed.Status = &status
}
if input.Priority != nil {
@@ -8,6 +8,8 @@ package validate
import (
"errors"
"fmt"
+ "slices"
+ "strings"
"git.secluded.site/go-lunatask"
"git.secluded.site/lune/internal/config"
@@ -32,6 +34,9 @@ func Reference(input string) (string, error) {
// ErrInvalidStatus indicates the status value is not recognized.
var ErrInvalidStatus = errors.New("invalid status")
+// ErrInvalidStatusForWorkflow indicates the status is not valid for the area's workflow.
+var ErrInvalidStatusForWorkflow = errors.New("invalid status for workflow")
+
// TaskStatus validates and normalizes a task status string.
// Returns the corresponding lunatask.TaskStatus or an error if invalid.
func TaskStatus(input string) (lunatask.TaskStatus, error) {
@@ -43,6 +48,38 @@ func TaskStatus(input string) (lunatask.TaskStatus, error) {
return status, nil
}
+// TaskStatusForWorkflow validates a status string against a specific workflow.
+// Returns the status if valid for the workflow, or an error listing valid options.
+func TaskStatusForWorkflow(input string, workflow lunatask.Workflow) (lunatask.TaskStatus, error) {
+ status, err := lunatask.ParseTaskStatus(input)
+ if err != nil {
+ return "", fmt.Errorf(
+ "%w: '%s' for %s; valid: %s",
+ ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+ )
+ }
+
+ if !slices.Contains(workflow.ValidStatuses(), status) {
+ return "", fmt.Errorf(
+ "%w: '%s' not valid for %s; valid: %s",
+ ErrInvalidStatusForWorkflow, input, workflow.Description(), formatValidStatuses(workflow),
+ )
+ }
+
+ return status, nil
+}
+
+func formatValidStatuses(workflow lunatask.Workflow) string {
+ statuses := workflow.ValidStatuses()
+ strs := make([]string, len(statuses))
+
+ for i, s := range statuses {
+ strs[i] = string(s)
+ }
+
+ return strings.Join(strs, ", ")
+}
+
// ErrInvalidMotivation indicates the motivation value is not recognized.
var ErrInvalidMotivation = errors.New("invalid motivation")