@@ -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)
+ }
+ })
+ }
+}