feat(deeplink): add UUID validation and helpers

Amolith created

Add ValidateUUID using github.com/google/uuid. Add Resource.Valid() and
Resource.String() methods. Refactor ParseDeepLink/BuildDeepLink to use
new helpers. UUIDs are now validated in both parse and build operations.

Assisted-by: Claude Opus 4.5 via Amp

Change summary

deeplink.go      |  65 ++++++++++++++++++------
deeplink_test.go | 133 +++++++++++++++++++++++++++++++++++++++++++------
go.mod           |   2 
go.sum           |   2 
4 files changed, 168 insertions(+), 34 deletions(-)

Detailed changes

deeplink.go 🔗

@@ -8,6 +8,8 @@ import (
 	"errors"
 	"fmt"
 	"strings"
+
+	"github.com/google/uuid"
 )
 
 // Errors returned by deep link operations.
@@ -16,7 +18,7 @@ var (
 	ErrInvalidDeepLink = errors.New("invalid deep link")
 	// ErrInvalidResource is returned when a resource type is unknown.
 	ErrInvalidResource = errors.New("invalid resource")
-	// ErrInvalidUUID is returned when the UUID portion is empty.
+	// ErrInvalidUUID is returned when the UUID portion is empty or malformed.
 	ErrInvalidUUID = errors.New("invalid UUID")
 )
 
@@ -33,9 +35,38 @@ const (
 	ResourceNotebook Resource = "notebooks"
 )
 
+// Valid reports whether r is a known resource type.
+func (r Resource) Valid() bool {
+	switch r {
+	case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook:
+		return true
+	}
+
+	return false
+}
+
+// String returns the resource type as a string.
+func (r Resource) String() string {
+	return string(r)
+}
+
+// ValidateUUID checks whether id is a valid UUID string.
+func ValidateUUID(id string) error {
+	if id == "" {
+		return fmt.Errorf("%w: empty", ErrInvalidUUID)
+	}
+
+	if _, err := uuid.Parse(id); err != nil {
+		return fmt.Errorf("%w: %q", ErrInvalidUUID, id)
+	}
+
+	return nil
+}
+
 // ParseDeepLink extracts resource type and UUID from a Lunatask deep link
 // or plain UUID. Accepts "lunatask://tasks/uuid" or plain UUID strings.
 // When a plain UUID is provided, the resource type will be empty.
+// The UUID is validated to be a well-formed UUID string.
 func ParseDeepLink(input string) (Resource, string, error) {
 	if input == "" {
 		return "", "", fmt.Errorf("%w: empty input", ErrInvalidDeepLink)
@@ -44,7 +75,11 @@ func ParseDeepLink(input string) (Resource, string, error) {
 	// Check for lunatask:// prefix
 	const prefix = "lunatask://"
 	if !strings.HasPrefix(input, prefix) {
-		// Treat as plain UUID
+		// Treat as plain UUID, validate format
+		if err := ValidateUUID(input); err != nil {
+			return "", "", err
+		}
+
 		return "", input, nil
 	}
 
@@ -57,31 +92,27 @@ func ParseDeepLink(input string) (Resource, string, error) {
 	}
 
 	resource := Resource(parts[0])
-	uuid := parts[1]
+	id := parts[1]
 
-	// Validate resource type
-	switch resource {
-	case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook:
-		// valid
-	default:
+	if !resource.Valid() {
 		return "", "", fmt.Errorf("%w: %q", ErrInvalidResource, resource)
 	}
 
-	return resource, uuid, nil
+	if err := ValidateUUID(id); err != nil {
+		return "", "", err
+	}
+
+	return resource, id, nil
 }
 
 // BuildDeepLink constructs a Lunatask deep link from resource type and ID.
-// Returns "lunatask://resource/uuid".
+// Returns "lunatask://resource/uuid". The ID is validated to be a well-formed UUID.
 func BuildDeepLink(resource Resource, id string) (string, error) {
-	if id == "" {
-		return "", ErrInvalidUUID
+	if err := ValidateUUID(id); err != nil {
+		return "", err
 	}
 
-	// Validate resource type
-	switch resource {
-	case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook:
-		// valid
-	default:
+	if !resource.Valid() {
 		return "", fmt.Errorf("%w: %q", ErrInvalidResource, resource)
 	}
 
@@ -14,6 +14,9 @@ import (
 func TestParseDeepLink(t *testing.T) {
 	t.Parallel()
 
+	validUUID := "12345678-1234-1234-1234-123456789012"
+	validUUID2 := "abcdef12-3456-7890-abcd-ef1234567890"
+
 	tests := []struct {
 		name         string
 		input        string
@@ -22,23 +25,25 @@ func TestParseDeepLink(t *testing.T) {
 		wantErr      error
 	}{
 		// Valid deep links
-		{"task_link", "lunatask://tasks/abc-123", lunatask.ResourceTask, "abc-123", nil},
-		{"area_link", "lunatask://areas/def-456", lunatask.ResourceArea, "def-456", nil},
-		{"goal_link", "lunatask://goals/ghi-789", lunatask.ResourceGoal, "ghi-789", nil},
-		{"note_link", "lunatask://notes/jkl-012", lunatask.ResourceNote, "jkl-012", nil},
-		{"person_link", "lunatask://people/mno-345", lunatask.ResourcePerson, "mno-345", nil},
-		{"notebook_link", "lunatask://notebooks/pqr-678", lunatask.ResourceNotebook, "pqr-678", nil},
+		{"task_link", "lunatask://tasks/" + validUUID, lunatask.ResourceTask, validUUID, nil},
+		{"area_link", "lunatask://areas/" + validUUID, lunatask.ResourceArea, validUUID, nil},
+		{"goal_link", "lunatask://goals/" + validUUID, lunatask.ResourceGoal, validUUID, nil},
+		{"note_link", "lunatask://notes/" + validUUID, lunatask.ResourceNote, validUUID, nil},
+		{"person_link", "lunatask://people/" + validUUID, lunatask.ResourcePerson, validUUID, nil},
+		{"notebook_link", "lunatask://notebooks/" + validUUID, lunatask.ResourceNotebook, validUUID, nil},
 
 		// Plain UUIDs
-		{"plain_uuid", "abc-123-def-456", "", "abc-123-def-456", nil},
-		{"uuid_only", "12345678-1234-1234-1234-123456789012", "", "12345678-1234-1234-1234-123456789012", nil},
+		{"plain_uuid", validUUID2, "", validUUID2, nil},
+		{"uuid_only", validUUID, "", validUUID, nil},
 
 		// Invalid inputs
 		{"empty", "", "", "", lunatask.ErrInvalidDeepLink},
-		{"invalid_resource", "lunatask://invalid/abc-123", "", "", lunatask.ErrInvalidResource},
+		{"invalid_resource", "lunatask://invalid/" + validUUID, "", "", lunatask.ErrInvalidResource},
 		{"missing_uuid", "lunatask://tasks/", "", "", lunatask.ErrInvalidDeepLink},
-		{"missing_resource", "lunatask:///abc-123", "", "", lunatask.ErrInvalidDeepLink},
+		{"missing_resource", "lunatask:///" + validUUID, "", "", lunatask.ErrInvalidDeepLink},
 		{"malformed", "lunatask://tasks", "", "", lunatask.ErrInvalidDeepLink},
+		{"invalid_uuid_in_link", "lunatask://tasks/not-a-uuid", "", "", lunatask.ErrInvalidUUID},
+		{"invalid_plain_uuid", "not-a-valid-uuid", "", "", lunatask.ErrInvalidUUID},
 	}
 
 	for _, testCase := range tests {
@@ -75,6 +80,8 @@ func TestParseDeepLink(t *testing.T) {
 func TestBuildDeepLink(t *testing.T) {
 	t.Parallel()
 
+	validUUID := "12345678-1234-1234-1234-123456789012"
+
 	tests := []struct {
 		name     string
 		resource lunatask.Resource
@@ -83,16 +90,17 @@ func TestBuildDeepLink(t *testing.T) {
 		wantErr  error
 	}{
 		// Valid builds
-		{"task", lunatask.ResourceTask, "abc-123", "lunatask://tasks/abc-123", nil},
-		{"area", lunatask.ResourceArea, "def-456", "lunatask://areas/def-456", nil},
-		{"goal", lunatask.ResourceGoal, "ghi-789", "lunatask://goals/ghi-789", nil},
-		{"note", lunatask.ResourceNote, "jkl-012", "lunatask://notes/jkl-012", nil},
-		{"person", lunatask.ResourcePerson, "mno-345", "lunatask://people/mno-345", nil},
-		{"notebook", lunatask.ResourceNotebook, "pqr-678", "lunatask://notebooks/pqr-678", nil},
+		{"task", lunatask.ResourceTask, validUUID, "lunatask://tasks/" + validUUID, nil},
+		{"area", lunatask.ResourceArea, validUUID, "lunatask://areas/" + validUUID, nil},
+		{"goal", lunatask.ResourceGoal, validUUID, "lunatask://goals/" + validUUID, nil},
+		{"note", lunatask.ResourceNote, validUUID, "lunatask://notes/" + validUUID, nil},
+		{"person", lunatask.ResourcePerson, validUUID, "lunatask://people/" + validUUID, nil},
+		{"notebook", lunatask.ResourceNotebook, validUUID, "lunatask://notebooks/" + validUUID, nil},
 
 		// Invalid inputs
 		{"empty_id", lunatask.ResourceTask, "", "", lunatask.ErrInvalidUUID},
-		{"invalid_resource", lunatask.Resource("invalid"), "abc-123", "", lunatask.ErrInvalidResource},
+		{"invalid_resource", lunatask.Resource("invalid"), validUUID, "", lunatask.ErrInvalidResource},
+		{"invalid_uuid", lunatask.ResourceTask, "not-a-uuid", "", lunatask.ErrInvalidUUID},
 	}
 
 	for _, testCase := range tests {
@@ -121,3 +129,94 @@ func TestBuildDeepLink(t *testing.T) {
 		})
 	}
 }
+
+func TestValidateUUID(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		wantErr bool
+	}{
+		{"valid_uuid", "12345678-1234-1234-1234-123456789012", false},
+		{"valid_uuid_lowercase", "abcdef12-3456-7890-abcd-ef1234567890", false},
+		{"valid_uuid_uppercase", "ABCDEF12-3456-7890-ABCD-EF1234567890", false},
+		{"valid_uuid_mixed", "AbCdEf12-3456-7890-AbCd-Ef1234567890", false},
+		{"empty", "", true},
+		{"invalid_format", "not-a-uuid", true},
+		{"too_short", "12345678-1234-1234-1234", true},
+		{"too_long", "12345678-1234-1234-1234-1234567890123", true},
+		{"missing_hyphens", "12345678123412341234123456789012", false}, // google/uuid accepts this
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			err := lunatask.ValidateUUID(testCase.input)
+			if (err != nil) != testCase.wantErr {
+				t.Errorf("ValidateUUID(%q) error = %v, wantErr %v", testCase.input, err, testCase.wantErr)
+			}
+
+			if testCase.wantErr && err != nil && !errors.Is(err, lunatask.ErrInvalidUUID) {
+				t.Errorf("ValidateUUID(%q) error = %v, want wrapped ErrInvalidUUID", testCase.input, err)
+			}
+		})
+	}
+}
+
+func TestResource_Valid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		resource lunatask.Resource
+		want     bool
+	}{
+		{"area", lunatask.ResourceArea, true},
+		{"goal", lunatask.ResourceGoal, true},
+		{"task", lunatask.ResourceTask, true},
+		{"note", lunatask.ResourceNote, true},
+		{"person", lunatask.ResourcePerson, true},
+		{"notebook", lunatask.ResourceNotebook, true},
+		{"invalid", lunatask.Resource("invalid"), false},
+		{"empty", lunatask.Resource(""), false},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := testCase.resource.Valid(); got != testCase.want {
+				t.Errorf("Resource(%q).Valid() = %v, want %v", testCase.resource, got, testCase.want)
+			}
+		})
+	}
+}
+
+func TestResource_String(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		resource lunatask.Resource
+		want     string
+	}{
+		{"area", lunatask.ResourceArea, "areas"},
+		{"goal", lunatask.ResourceGoal, "goals"},
+		{"task", lunatask.ResourceTask, "tasks"},
+		{"note", lunatask.ResourceNote, "notes"},
+		{"person", lunatask.ResourcePerson, "people"},
+		{"notebook", lunatask.ResourceNotebook, "notebooks"},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := testCase.resource.String(); got != testCase.want {
+				t.Errorf("Resource.String() = %q, want %q", got, testCase.want)
+			}
+		})
+	}
+}

go.mod 🔗

@@ -5,3 +5,5 @@
 module git.secluded.site/go-lunatask
 
 go 1.25.5
+
+require github.com/google/uuid v1.6.0

go.sum 🔗

@@ -0,0 +1,2 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=