diff --git a/Dockerfile.appview b/Dockerfile.appview index 14f10b4..217dfaf 100644 --- a/Dockerfile.appview +++ b/Dockerfile.appview @@ -1,6 +1,6 @@ # Production build for ATCR AppView # Result: ~30MB scratch image with static binary -FROM docker.io/golang:1.25.2-trixie AS builder +FROM docker.io/golang:1.25.4-trixie AS builder ENV DEBIAN_FRONTEND=noninteractive @@ -34,12 +34,12 @@ EXPOSE 5000 LABEL org.opencontainers.image.title="ATCR AppView" \ org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \ org.opencontainers.image.authors="ATCR Contributors" \ - org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \ - org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ + org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \ + org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.version="0.1.0" \ io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" \ - io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" + io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" ENTRYPOINT ["/atcr-appview"] CMD ["serve"] diff --git a/Dockerfile.dev b/Dockerfile.dev index e50d53d..63fd979 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,7 @@ # Development image with Air hot reload # Build: docker build -f Dockerfile.dev -t atcr-appview-dev . # Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev -FROM docker.io/golang:1.25.2-trixie +FROM docker.io/golang:1.25.4-trixie ENV DEBIAN_FRONTEND=noninteractive diff --git a/Dockerfile.hold b/Dockerfile.hold index c800b21..083e475 100644 --- a/Dockerfile.hold +++ b/Dockerfile.hold @@ -1,4 +1,4 @@ -FROM docker.io/golang:1.25.2-trixie AS builder +FROM docker.io/golang:1.25.4-trixie AS builder ENV DEBIAN_FRONTEND=noninteractive @@ -38,11 +38,11 @@ EXPOSE 8080 LABEL org.opencontainers.image.title="ATCR Hold Service" \ org.opencontainers.image.description="ATCR Hold Service - Bring Your Own Storage component for ATCR" \ org.opencontainers.image.authors="ATCR Contributors" \ - org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \ - org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ + org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \ + org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.version="0.1.0" \ io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE" \ - io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/hold.md" + io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/hold.md" ENTRYPOINT ["/atcr-hold"] diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 2839969..8b44256 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -192,17 +192,26 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request // Fetch README content if available var readmeHTML template.HTML - if repo.ReadmeURL != "" && h.ReadmeCache != nil { - // Fetch with timeout + if h.ReadmeCache != nil { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL) - if err != nil { - slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err) - // Continue without README on error - } else { - readmeHTML = template.HTML(html) + if repo.ReadmeURL != "" { + // Explicit io.atcr.readme takes priority + html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL) + if err != nil { + slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err) + } else { + readmeHTML = template.HTML(html) + } + } else if repo.SourceURL != "" { + // Derive README from org.opencontainers.image.source + html, err := h.ReadmeCache.GetFromSource(ctx, repo.SourceURL) + if err != nil { + slog.Debug("Failed to derive README from source", "url", repo.SourceURL, "error", err) + } else if html != "" { + readmeHTML = template.HTML(html) + } } } diff --git a/pkg/appview/readme/cache.go b/pkg/appview/readme/cache.go index c14c730..61d015e 100644 --- a/pkg/appview/readme/cache.go +++ b/pkg/appview/readme/cache.go @@ -11,6 +11,13 @@ import ( "time" ) +const ( + // negativeCacheTTL is the TTL for negative cache entries (no README found) + negativeCacheTTL = 15 * time.Minute + // sourceCachePrefix is the prefix for source-derived cache keys + sourceCachePrefix = "source:" +) + // Cache stores rendered README HTML in the database type Cache struct { db *sql.DB @@ -60,6 +67,63 @@ func (c *Cache) Get(ctx context.Context, readmeURL string) (string, error) { return html, nil } +// GetFromSource fetches a README by deriving the URL from a source repository URL. +// It tries main branch first, then falls back to master if 404. +// Returns empty string if no README found (cached as negative result with shorter TTL). +func (c *Cache) GetFromSource(ctx context.Context, sourceURL string) (string, error) { + cacheKey := sourceCachePrefix + sourceURL + + // Try to get from cache + html, fetchedAt, err := c.getFromDB(cacheKey) + if err == nil { + // Determine TTL based on whether this is a negative cache entry + ttl := c.ttl + if html == "" { + ttl = negativeCacheTTL + } + if time.Since(fetchedAt) < ttl { + return html, nil + } + } + + // Derive README URL and fetch + // Try main branch first + readmeURL := DeriveReadmeURL(sourceURL, "main") + if readmeURL == "" { + return "", nil // Unsupported platform, don't cache + } + + html, err = c.fetcher.FetchAndRender(ctx, readmeURL) + if err != nil { + if Is404(err) { + // Try master branch + readmeURL = DeriveReadmeURL(sourceURL, "master") + html, err = c.fetcher.FetchAndRender(ctx, readmeURL) + if err != nil { + if Is404(err) { + // No README on either branch - cache negative result + if cacheErr := c.storeInDB(cacheKey, ""); cacheErr != nil { + slog.Warn("Failed to cache negative README result", "error", cacheErr) + } + return "", nil + } + // Other error (network, etc.) - don't cache, allow retry + return "", err + } + } else { + // Other error (network, etc.) - don't cache, allow retry + return "", err + } + } + + // Store successful result in cache + if err := c.storeInDB(cacheKey, html); err != nil { + slog.Warn("Failed to cache README from source", "error", err) + } + + return html, nil +} + // getFromDB retrieves cached README from database func (c *Cache) getFromDB(readmeURL string) (string, time.Time, error) { var html string diff --git a/pkg/appview/readme/cache_test.go b/pkg/appview/readme/cache_test.go index 528eb7c..4ccf5c7 100644 --- a/pkg/appview/readme/cache_test.go +++ b/pkg/appview/readme/cache_test.go @@ -1,6 +1,14 @@ package readme -import "testing" +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) func TestCache_Struct(t *testing.T) { // Simple struct test @@ -10,4 +18,239 @@ func TestCache_Struct(t *testing.T) { } } -// TODO: Add cache operation tests +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + + // Create the readme_cache table + _, err = db.Exec(` + CREATE TABLE readme_cache ( + url TEXT PRIMARY KEY, + html TEXT NOT NULL, + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + t.Fatalf("Failed to create table: %v", err) + } + + return db +} + +func TestGetFromSource_UnsupportedPlatform(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + cache := NewCache(db, time.Hour) + ctx := context.Background() + + // Unsupported platform should return empty, no error + html, err := cache.GetFromSource(ctx, "https://bitbucket.org/user/repo") + if err != nil { + t.Errorf("Expected no error for unsupported platform, got: %v", err) + } + if html != "" { + t.Errorf("Expected empty string for unsupported platform, got: %q", html) + } +} + +func TestGetFromSource_CacheHit(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + cache := NewCache(db, time.Hour) + sourceURL := "https://github.com/test/repo" + cacheKey := sourceCachePrefix + sourceURL + expectedHTML := "

Cached Content

" + + // Pre-populate cache + _, err := db.Exec(` + INSERT INTO readme_cache (url, html, fetched_at) + VALUES (?, ?, ?) + `, cacheKey, expectedHTML, time.Now()) + if err != nil { + t.Fatalf("Failed to insert cache: %v", err) + } + + ctx := context.Background() + html, err := cache.GetFromSource(ctx, sourceURL) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if html != expectedHTML { + t.Errorf("Expected %q, got %q", expectedHTML, html) + } +} + +func TestGetFromSource_CacheExpired(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + cache := NewCache(db, time.Millisecond) // Very short TTL + sourceURL := "https://github.com/test/repo" + cacheKey := sourceCachePrefix + sourceURL + oldHTML := "

Old Content

" + + // Pre-populate cache with old timestamp + _, err := db.Exec(` + INSERT INTO readme_cache (url, html, fetched_at) + VALUES (?, ?, ?) + `, cacheKey, oldHTML, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("Failed to insert cache: %v", err) + } + + ctx := context.Background() + + // With expired cache and no network (GitHub won't respond), we expect an error + // but the function should try to fetch + _, err = cache.GetFromSource(ctx, sourceURL) + // We expect an error because we can't actually fetch from GitHub in tests + // The important thing is that it tried to fetch (didn't return cached content) + if err == nil { + t.Log("Note: GetFromSource returned no error - cache was expired and fetch was attempted") + } +} + +func TestGetFromSource_NegativeCache(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + cache := NewCache(db, time.Hour) + sourceURL := "https://github.com/test/repo" + cacheKey := sourceCachePrefix + sourceURL + + // Pre-populate cache with empty string (negative cache) + _, err := db.Exec(` + INSERT INTO readme_cache (url, html, fetched_at) + VALUES (?, ?, ?) + `, cacheKey, "", time.Now()) + if err != nil { + t.Fatalf("Failed to insert cache: %v", err) + } + + ctx := context.Background() + html, err := cache.GetFromSource(ctx, sourceURL) + if err != nil { + t.Errorf("Expected no error for negative cache hit, got: %v", err) + } + if html != "" { + t.Errorf("Expected empty string for negative cache hit, got: %q", html) + } +} + +func TestGetFromSource_NegativeCacheExpired(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + cache := NewCache(db, time.Hour) + sourceURL := "https://github.com/test/repo" + cacheKey := sourceCachePrefix + sourceURL + + // Pre-populate cache with expired negative cache (older than negativeCacheTTL) + _, err := db.Exec(` + INSERT INTO readme_cache (url, html, fetched_at) + VALUES (?, ?, ?) + `, cacheKey, "", time.Now().Add(-30*time.Minute)) // 30 min ago, negative TTL is 15 min + if err != nil { + t.Fatalf("Failed to insert cache: %v", err) + } + + ctx := context.Background() + + // With expired negative cache, it should try to fetch again + _, err = cache.GetFromSource(ctx, sourceURL) + // We expect an error because we can't actually fetch from GitHub + // The important thing is that it tried (didn't return empty from expired negative cache) + if err == nil { + t.Log("Note: GetFromSource attempted refetch after negative cache expired") + } +} + +func TestGetFromSource_EmptyURL(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + cache := NewCache(db, time.Hour) + ctx := context.Background() + + html, err := cache.GetFromSource(ctx, "") + if err != nil { + t.Errorf("Expected no error for empty URL, got: %v", err) + } + if html != "" { + t.Errorf("Expected empty string for empty URL, got: %q", html) + } +} + +func TestGetFromSource_UnsupportedPlatforms(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + cache := NewCache(db, time.Hour) + ctx := context.Background() + + unsupportedURLs := []string{ + "https://bitbucket.org/user/repo", + "https://sourcehut.org/user/repo", + "https://codeberg.org/user/repo", + "ftp://github.com/user/repo", + "not-a-url", + } + + for _, url := range unsupportedURLs { + html, err := cache.GetFromSource(ctx, url) + if err != nil { + t.Errorf("Expected no error for unsupported URL %q, got: %v", url, err) + } + if html != "" { + t.Errorf("Expected empty string for unsupported URL %q, got: %q", url, html) + } + } +} + +func TestIs404(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "404 error", + err: fmt.Errorf("unexpected status code: 404"), + want: true, + }, + { + name: "404 error with context", + err: fmt.Errorf("failed to fetch: unexpected status code: 404"), + want: true, + }, + { + name: "500 error", + err: fmt.Errorf("unexpected status code: 500"), + want: false, + }, + { + name: "network error", + err: fmt.Errorf("connection refused"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Is404(tt.err) + if got != tt.want { + t.Errorf("Is404(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/pkg/appview/readme/fetcher.go b/pkg/appview/readme/fetcher.go index 112e572..31f3795 100644 --- a/pkg/appview/readme/fetcher.go +++ b/pkg/appview/readme/fetcher.go @@ -180,6 +180,11 @@ func getBaseURL(u *url.URL) string { return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, path) } +// Is404 returns true if the error indicates a 404 Not Found response +func Is404(err error) bool { + return err != nil && strings.Contains(err.Error(), "unexpected status code: 404") +} + // rewriteRelativeURLs converts relative URLs to absolute URLs func rewriteRelativeURLs(html, baseURL string) string { if baseURL == "" { diff --git a/pkg/appview/readme/source.go b/pkg/appview/readme/source.go new file mode 100644 index 0000000..774b71a --- /dev/null +++ b/pkg/appview/readme/source.go @@ -0,0 +1,103 @@ +package readme + +import ( + "fmt" + "net/url" + "strings" +) + +// Platform represents a supported Git hosting platform +type Platform string + +const ( + PlatformGitHub Platform = "github" + PlatformGitLab Platform = "gitlab" + PlatformTangled Platform = "tangled" +) + +// ParseSourceURL extracts platform, user, and repo from a source repository URL. +// Returns ok=false if the URL is not a recognized pattern. +func ParseSourceURL(sourceURL string) (platform Platform, user, repo string, ok bool) { + if sourceURL == "" { + return "", "", "", false + } + + parsed, err := url.Parse(sourceURL) + if err != nil { + return "", "", "", false + } + + // Normalize: remove trailing slash and .git suffix + path := strings.TrimSuffix(parsed.Path, "/") + path = strings.TrimSuffix(path, ".git") + path = strings.TrimPrefix(path, "/") + + if path == "" { + return "", "", "", false + } + + host := strings.ToLower(parsed.Host) + + switch { + case host == "github.com": + // GitHub: github.com/{user}/{repo} + parts := strings.SplitN(path, "/", 3) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + return PlatformGitHub, parts[0], parts[1], true + + case host == "gitlab.com": + // GitLab: gitlab.com/{user}/{repo} or gitlab.com/{group}/{subgroup}/{repo} + // For nested groups, user = everything except last part, repo = last part + lastSlash := strings.LastIndex(path, "/") + if lastSlash == -1 || lastSlash == 0 { + return "", "", "", false + } + user = path[:lastSlash] + repo = path[lastSlash+1:] + if user == "" || repo == "" { + return "", "", "", false + } + return PlatformGitLab, user, repo, true + + case host == "tangled.org" || host == "tangled.sh": + // Tangled: tangled.org/{user}/{repo} or tangled.sh/@{user}/{repo} (legacy) + // Strip leading @ from user if present + path = strings.TrimPrefix(path, "@") + parts := strings.SplitN(path, "/", 3) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + return PlatformTangled, parts[0], parts[1], true + + default: + return "", "", "", false + } +} + +// DeriveReadmeURL converts a source repository URL to a raw README URL. +// Returns empty string if platform is not supported. +func DeriveReadmeURL(sourceURL, branch string) string { + platform, user, repo, ok := ParseSourceURL(sourceURL) + if !ok { + return "" + } + + switch platform { + case PlatformGitHub: + // https://raw.githubusercontent.com/{user}/{repo}/refs/heads/{branch}/README.md + return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/refs/heads/%s/README.md", user, repo, branch) + + case PlatformGitLab: + // https://gitlab.com/{user}/{repo}/-/raw/{branch}/README.md + return fmt.Sprintf("https://gitlab.com/%s/%s/-/raw/%s/README.md", user, repo, branch) + + case PlatformTangled: + // https://tangled.org/{user}/{repo}/raw/{branch}/README.md + return fmt.Sprintf("https://tangled.org/%s/%s/raw/%s/README.md", user, repo, branch) + + default: + return "" + } +} diff --git a/pkg/appview/readme/source_test.go b/pkg/appview/readme/source_test.go new file mode 100644 index 0000000..d0fa68f --- /dev/null +++ b/pkg/appview/readme/source_test.go @@ -0,0 +1,241 @@ +package readme + +import ( + "testing" +) + +func TestParseSourceURL(t *testing.T) { + tests := []struct { + name string + sourceURL string + wantPlatform Platform + wantUser string + wantRepo string + wantOK bool + }{ + // GitHub + { + name: "github standard", + sourceURL: "https://github.com/bigmoves/quickslice", + wantPlatform: PlatformGitHub, + wantUser: "bigmoves", + wantRepo: "quickslice", + wantOK: true, + }, + { + name: "github with .git suffix", + sourceURL: "https://github.com/user/repo.git", + wantPlatform: PlatformGitHub, + wantUser: "user", + wantRepo: "repo", + wantOK: true, + }, + { + name: "github with trailing slash", + sourceURL: "https://github.com/user/repo/", + wantPlatform: PlatformGitHub, + wantUser: "user", + wantRepo: "repo", + wantOK: true, + }, + { + name: "github with subpath (ignored)", + sourceURL: "https://github.com/user/repo/tree/main", + wantPlatform: PlatformGitHub, + wantUser: "user", + wantRepo: "repo", + wantOK: true, + }, + { + name: "github user only", + sourceURL: "https://github.com/user", + wantOK: false, + }, + + // GitLab + { + name: "gitlab standard", + sourceURL: "https://gitlab.com/user/repo", + wantPlatform: PlatformGitLab, + wantUser: "user", + wantRepo: "repo", + wantOK: true, + }, + { + name: "gitlab nested groups", + sourceURL: "https://gitlab.com/group/subgroup/repo", + wantPlatform: PlatformGitLab, + wantUser: "group/subgroup", + wantRepo: "repo", + wantOK: true, + }, + { + name: "gitlab deep nested groups", + sourceURL: "https://gitlab.com/a/b/c/d/repo", + wantPlatform: PlatformGitLab, + wantUser: "a/b/c/d", + wantRepo: "repo", + wantOK: true, + }, + { + name: "gitlab with .git suffix", + sourceURL: "https://gitlab.com/user/repo.git", + wantPlatform: PlatformGitLab, + wantUser: "user", + wantRepo: "repo", + wantOK: true, + }, + + // Tangled + { + name: "tangled standard", + sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry", + wantPlatform: PlatformTangled, + wantUser: "evan.jarrett.net", + wantRepo: "at-container-registry", + wantOK: true, + }, + { + name: "tangled with legacy @ prefix", + sourceURL: "https://tangled.org/@evan.jarrett.net/at-container-registry", + wantPlatform: PlatformTangled, + wantUser: "evan.jarrett.net", + wantRepo: "at-container-registry", + wantOK: true, + }, + { + name: "tangled.sh domain", + sourceURL: "https://tangled.sh/user/repo", + wantPlatform: PlatformTangled, + wantUser: "user", + wantRepo: "repo", + wantOK: true, + }, + { + name: "tangled with trailing slash", + sourceURL: "https://tangled.org/user/repo/", + wantPlatform: PlatformTangled, + wantUser: "user", + wantRepo: "repo", + wantOK: true, + }, + + // Unsupported / Invalid + { + name: "unsupported platform", + sourceURL: "https://bitbucket.org/user/repo", + wantOK: false, + }, + { + name: "empty url", + sourceURL: "", + wantOK: false, + }, + { + name: "invalid url", + sourceURL: "not-a-url", + wantOK: false, + }, + { + name: "just host", + sourceURL: "https://github.com", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + platform, user, repo, ok := ParseSourceURL(tt.sourceURL) + if ok != tt.wantOK { + t.Errorf("ParseSourceURL(%q) ok = %v, want %v", tt.sourceURL, ok, tt.wantOK) + return + } + if !tt.wantOK { + return + } + if platform != tt.wantPlatform { + t.Errorf("ParseSourceURL(%q) platform = %v, want %v", tt.sourceURL, platform, tt.wantPlatform) + } + if user != tt.wantUser { + t.Errorf("ParseSourceURL(%q) user = %q, want %q", tt.sourceURL, user, tt.wantUser) + } + if repo != tt.wantRepo { + t.Errorf("ParseSourceURL(%q) repo = %q, want %q", tt.sourceURL, repo, tt.wantRepo) + } + }) + } +} + +func TestDeriveReadmeURL(t *testing.T) { + tests := []struct { + name string + sourceURL string + branch string + want string + }{ + // GitHub + { + name: "github main", + sourceURL: "https://github.com/bigmoves/quickslice", + branch: "main", + want: "https://raw.githubusercontent.com/bigmoves/quickslice/refs/heads/main/README.md", + }, + { + name: "github master", + sourceURL: "https://github.com/user/repo", + branch: "master", + want: "https://raw.githubusercontent.com/user/repo/refs/heads/master/README.md", + }, + + // GitLab + { + name: "gitlab main", + sourceURL: "https://gitlab.com/user/repo", + branch: "main", + want: "https://gitlab.com/user/repo/-/raw/main/README.md", + }, + { + name: "gitlab nested groups", + sourceURL: "https://gitlab.com/group/subgroup/repo", + branch: "main", + want: "https://gitlab.com/group/subgroup/repo/-/raw/main/README.md", + }, + + // Tangled + { + name: "tangled main", + sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry", + branch: "main", + want: "https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/README.md", + }, + { + name: "tangled legacy @ prefix", + sourceURL: "https://tangled.org/@user/repo", + branch: "main", + want: "https://tangled.org/user/repo/raw/main/README.md", + }, + + // Unsupported + { + name: "unsupported platform", + sourceURL: "https://bitbucket.org/user/repo", + branch: "main", + want: "", + }, + { + name: "empty url", + sourceURL: "", + branch: "main", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DeriveReadmeURL(tt.sourceURL, tt.branch) + if got != tt.want { + t.Errorf("DeriveReadmeURL(%q, %q) = %q, want %q", tt.sourceURL, tt.branch, got, tt.want) + } + }) + } +}