From 5698fed3d5d3c04813388789db6fed5e05f828c1 Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 22 Dec 2025 08:39:37 -0700 Subject: [PATCH] feat(deeplink): add parse and build functions Resource type with 6 constants (areas, goals, tasks, notes, people, notebooks). ParseDeepLink accepts lunatask://resource/uuid or plain UUID. Assisted-by: Claude Sonnet 4 via Crush --- deeplink.go | 89 ++++++++++++++++++++++++++++++++++ deeplink_test.go | 123 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 deeplink.go create mode 100644 deeplink_test.go diff --git a/deeplink.go b/deeplink.go new file mode 100644 index 0000000000000000000000000000000000000000..9b5a4746e1cd45683019cf057fcb9f9159ba49fd --- /dev/null +++ b/deeplink.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask + +import ( + "errors" + "fmt" + "strings" +) + +// Errors returned by deep link operations. +var ( + // ErrInvalidDeepLink is returned when parsing a malformed deep link. + 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 = errors.New("invalid UUID") +) + +// Resource represents a Lunatask resource type for deep links. +type Resource string + +// Valid resource types for deep links. +const ( + ResourceArea Resource = "areas" + ResourceGoal Resource = "goals" + ResourceTask Resource = "tasks" + ResourceNote Resource = "notes" + ResourcePerson Resource = "people" + ResourceNotebook Resource = "notebooks" +) + +// 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. +func ParseDeepLink(input string) (Resource, string, error) { + if input == "" { + return "", "", fmt.Errorf("%w: empty input", ErrInvalidDeepLink) + } + + // Check for lunatask:// prefix + const prefix = "lunatask://" + if !strings.HasPrefix(input, prefix) { + // Treat as plain UUID + return "", input, nil + } + + // Remove prefix and split + remainder := strings.TrimPrefix(input, prefix) + parts := strings.SplitN(remainder, "/", 2) //nolint:mnd // split resource/uuid + + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("%w: %q", ErrInvalidDeepLink, input) + } + + resource := Resource(parts[0]) + uuid := parts[1] + + // Validate resource type + switch resource { + case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook: + // valid + default: + return "", "", fmt.Errorf("%w: %q", ErrInvalidResource, resource) + } + + return resource, uuid, nil +} + +// BuildDeepLink constructs a Lunatask deep link from resource type and ID. +// Returns "lunatask://resource/uuid". +func BuildDeepLink(resource Resource, id string) (string, error) { + if id == "" { + return "", ErrInvalidUUID + } + + // Validate resource type + switch resource { + case ResourceArea, ResourceGoal, ResourceTask, ResourceNote, ResourcePerson, ResourceNotebook: + // valid + default: + return "", fmt.Errorf("%w: %q", ErrInvalidResource, resource) + } + + return fmt.Sprintf("lunatask://%s/%s", resource, id), nil +} diff --git a/deeplink_test.go b/deeplink_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a2b1739450f87f3937fd8bb573206b8fb0ab3783 --- /dev/null +++ b/deeplink_test.go @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "errors" + "testing" + + lunatask "git.secluded.site/go-lunatask" +) + +func TestParseDeepLink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantResource lunatask.Resource + wantUUID string + 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}, + + // 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}, + + // Invalid inputs + {"empty", "", "", "", lunatask.ErrInvalidDeepLink}, + {"invalid_resource", "lunatask://invalid/abc-123", "", "", lunatask.ErrInvalidResource}, + {"missing_uuid", "lunatask://tasks/", "", "", lunatask.ErrInvalidDeepLink}, + {"missing_resource", "lunatask:///abc-123", "", "", lunatask.ErrInvalidDeepLink}, + {"malformed", "lunatask://tasks", "", "", lunatask.ErrInvalidDeepLink}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + resource, uuid, err := lunatask.ParseDeepLink(testCase.input) + + if testCase.wantErr != nil { + if !errors.Is(err, testCase.wantErr) { + t.Errorf("ParseDeepLink(%q) error = %v, want %v", testCase.input, err, testCase.wantErr) + } + + return + } + + if err != nil { + t.Errorf("ParseDeepLink(%q) unexpected error = %v", testCase.input, err) + + return + } + + if resource != testCase.wantResource { + t.Errorf("ParseDeepLink(%q) resource = %q, want %q", testCase.input, resource, testCase.wantResource) + } + + if uuid != testCase.wantUUID { + t.Errorf("ParseDeepLink(%q) uuid = %q, want %q", testCase.input, uuid, testCase.wantUUID) + } + }) + } +} + +func TestBuildDeepLink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resource lunatask.Resource + id string + want string + 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}, + + // Invalid inputs + {"empty_id", lunatask.ResourceTask, "", "", lunatask.ErrInvalidUUID}, + {"invalid_resource", lunatask.Resource("invalid"), "abc-123", "", lunatask.ErrInvalidResource}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, err := lunatask.BuildDeepLink(testCase.resource, testCase.id) + + if testCase.wantErr != nil { + if !errors.Is(err, testCase.wantErr) { + t.Errorf("BuildDeepLink(%q, %q) error = %v, want %v", testCase.resource, testCase.id, err, testCase.wantErr) + } + + return + } + + if err != nil { + t.Errorf("BuildDeepLink(%q, %q) unexpected error = %v", testCase.resource, testCase.id, err) + + return + } + + if got != testCase.want { + t.Errorf("BuildDeepLink(%q, %q) = %q, want %q", testCase.resource, testCase.id, got, testCase.want) + } + }) + } +}