feat(marketplace): add category-aware index builder
Some checks failed
Security Scan / security (push) Failing after 11s
Test / test (push) Successful in 2m11s

Propagate category metadata while building marketplace indexes and deduplicate the category list for consumers such as the UI.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 06:03:23 +00:00
parent 82c25469e8
commit 1f98d7ab8a
2 changed files with 145 additions and 0 deletions

77
marketplace/indexer.go Normal file
View file

@ -0,0 +1,77 @@
// SPDX-License-Identifier: EUPL-1.2
package marketplace
import (
"sort"
"dappco.re/go/core/io"
"dappco.re/go/core/scm/manifest"
)
// IndexOptions controls how BuildIndex populates marketplace metadata.
// Usage: IndexOptions{...}
type IndexOptions struct {
// Org is the default organisation used when constructing repo URLs.
Org string
// ForgeURL is the base URL used when constructing repo URLs.
ForgeURL string
// CategoryFn assigns a category to a module code.
CategoryFn func(code string) string
}
// BuildIndex reads .core/manifest.yaml from each repository root and produces
// a marketplace index. Repositories without a manifest are skipped silently.
// Categories are deduplicated and sorted.
// Usage: BuildIndex(...)
func BuildIndex(medium io.Medium, repoPaths []string, opts IndexOptions) (*Index, error) {
idx := &Index{
Version: IndexVersion,
}
seen := make(map[string]bool)
categories := make(map[string]bool)
for _, repoPath := range repoPaths {
m, err := manifest.Load(medium, repoPath)
if err != nil {
continue
}
if m == nil || m.Code == "" {
continue
}
if seen[m.Code] {
continue
}
seen[m.Code] = true
module := Module{
Code: m.Code,
Name: m.Name,
}
if opts.ForgeURL != "" && opts.Org != "" {
module.Repo = opts.ForgeURL + "/" + opts.Org + "/" + m.Code
}
if opts.CategoryFn != nil {
module.Category = opts.CategoryFn(m.Code)
}
if module.Category != "" {
categories[module.Category] = true
}
idx.Modules = append(idx.Modules, module)
}
sort.Slice(idx.Modules, func(i, j int) bool {
return idx.Modules[i].Code < idx.Modules[j].Code
})
for category := range categories {
idx.Categories = append(idx.Categories, category)
}
sort.Strings(idx.Categories)
return idx, nil
}

View file

@ -0,0 +1,68 @@
// SPDX-License-Identifier: EUPL-1.2
package marketplace
import (
"testing"
"dappco.re/go/core/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildIndex_Good_CategoriesAndRepoURLs_Good(t *testing.T) {
medium := io.NewMockMedium()
require.NoError(t, medium.Write("/repos/a/.core/manifest.yaml", `
code: a
name: Alpha
version: 1.0.0
`))
require.NoError(t, medium.Write("/repos/b/.core/manifest.yaml", `
code: b
name: Beta
version: 1.0.0
`))
require.NoError(t, medium.Write("/repos/c/.core/manifest.yaml", `
code: c
name: Gamma
version: 1.0.0
`))
idx, err := BuildIndex(medium, []string{"/repos/a", "/repos/b", "/repos/c"}, IndexOptions{
ForgeURL: "https://forge.example.com",
Org: "core",
CategoryFn: func(code string) string {
switch code {
case "a", "b":
return "tools"
default:
return "products"
}
},
})
require.NoError(t, err)
require.Len(t, idx.Modules, 3)
assert.Equal(t, "a", idx.Modules[0].Code)
assert.Equal(t, "https://forge.example.com/core/a", idx.Modules[0].Repo)
assert.Equal(t, "tools", idx.Modules[0].Category)
assert.Equal(t, []string{"products", "tools"}, idx.Categories)
}
func TestBuildIndex_Good_SkipsMissingManifest_Good(t *testing.T) {
medium := io.NewMockMedium()
require.NoError(t, medium.Write("/repos/one/.core/manifest.yaml", `
code: one
name: One
version: 1.0.0
`))
idx, err := BuildIndex(medium, []string{"/repos/one", "/repos/missing"}, IndexOptions{})
require.NoError(t, err)
require.Len(t, idx.Modules, 1)
assert.Equal(t, "one", idx.Modules[0].Code)
assert.Empty(t, idx.Categories)
}