@@ -0,0 +1,89 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+ }
+ })
+ }
+}