feat(deeplink): add parse and build functions

Amolith created

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

Change summary

deeplink.go      |  89 ++++++++++++++++++++++++++++++++++++
deeplink_test.go | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 212 insertions(+)

Detailed changes

deeplink.go 🔗

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