feat(validate): add validation for enum types

Amolith created

Add validation functions that lowercase and match against known
constants for TaskStatus, Motivation, and RelationshipStrength.

Wire validation into task add/update/list to reject invalid input early
with clear error messages instead of passing bogus values to the API.

Assisted-by: Claude Opus 4 via Crush

Change summary

cmd/task/add.go               | 15 ++++++++-
cmd/task/list.go              | 21 +++++++++++++
cmd/task/update.go            | 14 ++++++++-
internal/validate/validate.go | 55 +++++++++++++++++++++++++++++++++++++
4 files changed, 100 insertions(+), 5 deletions(-)

Detailed changes

cmd/task/add.go 🔗

@@ -17,6 +17,7 @@ import (
 	"git.secluded.site/lune/internal/config"
 	"git.secluded.site/lune/internal/dateutil"
 	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
 	"github.com/spf13/cobra"
 )
 
@@ -152,7 +153,12 @@ func applyAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskBuilder) error {
 
 func applyOptionalFlags(cmd *cobra.Command, builder *lunatask.TaskBuilder) error {
 	if status, _ := cmd.Flags().GetString("status"); status != "" {
-		builder.WithStatus(lunatask.TaskStatus(status))
+		s, err := validate.TaskStatus(status)
+		if err != nil {
+			return err
+		}
+
+		builder.WithStatus(s)
 	}
 
 	if note, _ := cmd.Flags().GetString("note"); note != "" {
@@ -178,7 +184,12 @@ func applyOptionalFlags(cmd *cobra.Command, builder *lunatask.TaskBuilder) error
 	}
 
 	if motivation, _ := cmd.Flags().GetString("motivation"); motivation != "" {
-		builder.WithMotivation(lunatask.Motivation(motivation))
+		m, err := validate.Motivation(motivation)
+		if err != nil {
+			return err
+		}
+
+		builder.WithMotivation(m)
 	}
 
 	applyEisenhower(cmd, builder)

cmd/task/list.go 🔗

@@ -15,6 +15,7 @@ import (
 	"git.secluded.site/lune/internal/completion"
 	"git.secluded.site/lune/internal/config"
 	"git.secluded.site/lune/internal/ui"
+	"git.secluded.site/lune/internal/validate"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/lipgloss/table"
 	"github.com/spf13/cobra"
@@ -64,7 +65,11 @@ func runList(cmd *cobra.Command, _ []string) error {
 		return err
 	}
 
-	statusFilter := mustGetStringFlag(cmd, "status")
+	statusFilter, err := resolveStatusFilter(cmd)
+	if err != nil {
+		return err
+	}
+
 	showAll := mustGetBoolFlag(cmd, "all")
 	tasks = applyFilters(tasks, areaID, statusFilter, showAll)
 
@@ -136,6 +141,20 @@ func resolveAreaFilter(cmd *cobra.Command) (string, error) {
 	return area.ID, nil
 }
 
+func resolveStatusFilter(cmd *cobra.Command) (string, error) {
+	status := mustGetStringFlag(cmd, "status")
+	if status == "" {
+		return "", nil
+	}
+
+	s, err := validate.TaskStatus(status)
+	if err != nil {
+		return "", err
+	}
+
+	return string(s), nil
+}
+
 func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task {
 	filtered := make([]lunatask.Task, 0, len(tasks))
 	today := time.Now().Truncate(24 * time.Hour)

cmd/task/update.go 🔗

@@ -147,7 +147,12 @@ func applyUpdateAreaAndGoal(cmd *cobra.Command, builder *lunatask.TaskUpdateBuil
 
 func applyUpdateFlags(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) error {
 	if status, _ := cmd.Flags().GetString("status"); status != "" {
-		builder.WithStatus(lunatask.TaskStatus(status))
+		s, err := validate.TaskStatus(status)
+		if err != nil {
+			return err
+		}
+
+		builder.WithStatus(s)
 	}
 
 	if note, _ := cmd.Flags().GetString("note"); note != "" {
@@ -173,7 +178,12 @@ func applyUpdateFlags(cmd *cobra.Command, builder *lunatask.TaskUpdateBuilder) e
 	}
 
 	if motivation, _ := cmd.Flags().GetString("motivation"); motivation != "" {
-		builder.WithMotivation(lunatask.Motivation(motivation))
+		m, err := validate.Motivation(motivation)
+		if err != nil {
+			return err
+		}
+
+		builder.WithMotivation(m)
 	}
 
 	applyUpdateEisenhower(cmd, builder)

internal/validate/validate.go 🔗

@@ -8,7 +8,9 @@ package validate
 import (
 	"errors"
 	"fmt"
+	"strings"
 
+	"git.secluded.site/go-lunatask"
 	"github.com/google/uuid"
 
 	"git.secluded.site/lune/internal/deeplink"
@@ -41,3 +43,56 @@ func Reference(input string) (string, error) {
 
 	return id, nil
 }
+
+// ErrInvalidStatus indicates the status value is not recognized.
+var ErrInvalidStatus = errors.New("invalid status")
+
+// 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) {
+	status := lunatask.TaskStatus(strings.ToLower(input))
+
+	switch status {
+	case lunatask.StatusLater, lunatask.StatusNext, lunatask.StatusStarted,
+		lunatask.StatusWaiting, lunatask.StatusCompleted:
+		return status, nil
+	default:
+		return "", fmt.Errorf("%w: %s", ErrInvalidStatus, input)
+	}
+}
+
+// ErrInvalidMotivation indicates the motivation value is not recognized.
+var ErrInvalidMotivation = errors.New("invalid motivation")
+
+// Motivation validates and normalizes a motivation string.
+// Returns the corresponding lunatask.Motivation or an error if invalid.
+func Motivation(input string) (lunatask.Motivation, error) {
+	motivation := lunatask.Motivation(strings.ToLower(input))
+
+	switch motivation {
+	case lunatask.MotivationUnknown, lunatask.MotivationMust,
+		lunatask.MotivationShould, lunatask.MotivationWant:
+		return motivation, nil
+	default:
+		return "", fmt.Errorf("%w: %s", ErrInvalidMotivation, input)
+	}
+}
+
+// ErrInvalidRelationship indicates the relationship strength is not recognized.
+var ErrInvalidRelationship = errors.New("invalid relationship strength")
+
+// RelationshipStrength validates and normalizes a relationship strength string.
+// Returns the corresponding lunatask.RelationshipStrength or an error if invalid.
+func RelationshipStrength(input string) (lunatask.RelationshipStrength, error) {
+	rel := lunatask.RelationshipStrength(strings.ToLower(input))
+
+	switch rel {
+	case lunatask.RelationshipFamily, lunatask.RelationshipIntimateFriend,
+		lunatask.RelationshipCloseFriend, lunatask.RelationshipCasualFriend,
+		lunatask.RelationshipAcquaintance, lunatask.RelationshipBusiness,
+		lunatask.RelationshipAlmostStranger:
+		return rel, nil
+	default:
+		return "", fmt.Errorf("%w: %s", ErrInvalidRelationship, input)
+	}
+}