Compare commits
8 Commits
ba87684696
...
bb3ddfe1bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb3ddfe1bd | ||
|
|
5d368da839 | ||
|
|
e16655aed8 | ||
|
|
03d32aba26 | ||
|
|
533632a3bb | ||
|
|
6bc0f98979 | ||
|
|
d53c3d8325 | ||
|
|
ed365f9030 |
@ -34,6 +34,7 @@ func newPluginCmd() *cobra.Command {
|
||||
newPluginDeleteVersionCmd(),
|
||||
newPluginBumpCmd(),
|
||||
newPluginVersionCmd(),
|
||||
newPluginTagsCmd(),
|
||||
)
|
||||
return c
|
||||
}
|
||||
@ -207,14 +208,14 @@ func parsePrivateCoord(s string) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
rest := strings.TrimPrefix(s, "@")
|
||||
slash := strings.IndexByte(rest, '/')
|
||||
if slash < 0 {
|
||||
before, after, ok := strings.Cut(rest, "/")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("expected @%s/<name>, got %q", core.PrivateScopeSlug, s)
|
||||
}
|
||||
if rest[:slash] != core.PrivateScopeSlug {
|
||||
return "", fmt.Errorf("only @%s scope is supported for delete; got @%s", core.PrivateScopeSlug, rest[:slash])
|
||||
if before != core.PrivateScopeSlug {
|
||||
return "", fmt.Errorf("only @%s scope is supported for delete; got @%s", core.PrivateScopeSlug, before)
|
||||
}
|
||||
return rest[slash+1:], nil
|
||||
return after, nil
|
||||
}
|
||||
|
||||
func printPrivateSection(ctx context.Context, cli *orchclient.Client, hc creds.HostCreds) {
|
||||
@ -360,6 +361,10 @@ func newPluginInitCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tags, err := promptTagsWithDefault(ctx, cli, scanner, kind, existing.Plugin.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createReq := &v1.CreatePluginRequest{
|
||||
ScopeSlug: scopeAPISlug(scope),
|
||||
@ -385,7 +390,7 @@ func newPluginInitCmd() *cobra.Command {
|
||||
if private {
|
||||
modScope = ""
|
||||
}
|
||||
if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, private); err != nil {
|
||||
if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, tags, private); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("plugin.mod updated")
|
||||
@ -600,6 +605,59 @@ func promptCategoriesWithDefault(ctx context.Context, cli *orchclient.Client, sc
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetchPopularTagsForPrompt returns a comma-joined "tag (count)" string of the
|
||||
// top tags for the given kind, or "" if the orchestrator lookup fails. The
|
||||
// current implementation always returns "" because core's copy of the
|
||||
// orchestrator proto bindings does not yet expose ListTags; once the bindings
|
||||
// are regenerated this body becomes a best-effort PluginRegistryService.ListTags
|
||||
// call. Callers must already tolerate an empty return.
|
||||
func fetchPopularTagsForPrompt(ctx context.Context, cli *orchclient.Client, kind string) string {
|
||||
_ = ctx
|
||||
_ = cli
|
||||
_ = kind
|
||||
return ""
|
||||
}
|
||||
|
||||
// promptTagsWithDefault prompts the user for free-form tags. Best-effort fetches
|
||||
// the top-20 most-used tags via ListTags(kind) to surface popular suggestions;
|
||||
// if that call fails (offline, etc.) it falls back to a plain prompt with a
|
||||
// one-line warning. Empty input keeps `current`. Validates with NormalizeTags;
|
||||
// on error, prints the issue and reprompts (one re-try, then bails).
|
||||
func promptTagsWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, kind string, current []string) ([]string, error) {
|
||||
popular := fetchPopularTagsForPrompt(ctx, cli, kind)
|
||||
|
||||
for attempt := range 2 {
|
||||
if len(current) > 0 {
|
||||
fmt.Printf("Tags (current: %s)\n", strings.Join(current, ", "))
|
||||
} else {
|
||||
fmt.Println("Tags (current: none)")
|
||||
}
|
||||
if popular != "" {
|
||||
fmt.Printf("Popular: %s\n", popular)
|
||||
} else if attempt == 0 {
|
||||
fmt.Println("(could not fetch popular tags — offline or unauthenticated)")
|
||||
}
|
||||
fmt.Print("Enter comma-separated tags (blank to keep current): ")
|
||||
if !scanner.Scan() {
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
}
|
||||
raw := strings.TrimSpace(scanner.Text())
|
||||
if raw == "" {
|
||||
return current, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out, err := core.NormalizeTags(parts)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
fmt.Printf(" %s\n", err.Error())
|
||||
if attempt == 0 {
|
||||
fmt.Println(" Try again:")
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("tag input failed validation after 2 attempts")
|
||||
}
|
||||
|
||||
// pickedSet returns a set of the given slugs for O(1) membership checks.
|
||||
func pickedSet(slugs []string) map[string]struct{} {
|
||||
s := make(map[string]struct{}, len(slugs))
|
||||
@ -965,7 +1023,7 @@ func submodulePaths(repoDir string) []string {
|
||||
return nil
|
||||
}
|
||||
var paths []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
|
||||
// each line is "submodule.<name>.path <path>"
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
@ -975,7 +1033,7 @@ func submodulePaths(repoDir string) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
func upsertPluginMod(scope, name, displayName, description, kind string, categories []string, private bool) error {
|
||||
func upsertPluginMod(scope, name, displayName, description, kind string, categories, tags []string, private bool) error {
|
||||
const file = "plugin.mod"
|
||||
existing, _ := os.ReadFile(file)
|
||||
mod, _ := core.ParseModFull(existing)
|
||||
@ -991,6 +1049,7 @@ func upsertPluginMod(scope, name, displayName, description, kind string, categor
|
||||
mod.Plugin.Description = description
|
||||
mod.Plugin.Kind = kind
|
||||
mod.Plugin.Categories = categories
|
||||
mod.Plugin.Tags = tags
|
||||
mod.Plugin.Private = private
|
||||
return writeMod(file, mod)
|
||||
}
|
||||
@ -998,36 +1057,43 @@ func upsertPluginMod(scope, name, displayName, description, kind string, categor
|
||||
func writeMod(path string, m *core.ModFile) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("[plugin]\n")
|
||||
b.WriteString(fmt.Sprintf("name = %q\n", m.Plugin.Name))
|
||||
fmt.Fprintf(&b, "name = %q\n", m.Plugin.Name)
|
||||
if m.Plugin.DisplayName != "" {
|
||||
b.WriteString(fmt.Sprintf("display_name = %q\n", m.Plugin.DisplayName))
|
||||
fmt.Fprintf(&b, "display_name = %q\n", m.Plugin.DisplayName)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("scope = %q\n", m.Plugin.Scope))
|
||||
b.WriteString(fmt.Sprintf("version = %q\n", m.Plugin.Version))
|
||||
fmt.Fprintf(&b, "scope = %q\n", m.Plugin.Scope)
|
||||
fmt.Fprintf(&b, "version = %q\n", m.Plugin.Version)
|
||||
if m.Plugin.Description != "" {
|
||||
b.WriteString(fmt.Sprintf("description = %q\n", m.Plugin.Description))
|
||||
fmt.Fprintf(&b, "description = %q\n", m.Plugin.Description)
|
||||
}
|
||||
if m.Plugin.Kind != "" {
|
||||
b.WriteString(fmt.Sprintf("kind = %q\n", m.Plugin.Kind))
|
||||
fmt.Fprintf(&b, "kind = %q\n", m.Plugin.Kind)
|
||||
}
|
||||
if len(m.Plugin.Categories) > 0 {
|
||||
quoted := make([]string, len(m.Plugin.Categories))
|
||||
for i, c := range m.Plugin.Categories {
|
||||
quoted[i] = fmt.Sprintf("%q", c)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("categories = [%s]\n", strings.Join(quoted, ", ")))
|
||||
fmt.Fprintf(&b, "categories = [%s]\n", strings.Join(quoted, ", "))
|
||||
}
|
||||
if len(m.Plugin.Tags) > 0 {
|
||||
quoted := make([]string, len(m.Plugin.Tags))
|
||||
for i, t := range m.Plugin.Tags {
|
||||
quoted[i] = fmt.Sprintf("%q", t)
|
||||
}
|
||||
fmt.Fprintf(&b, "tags = [%s]\n", strings.Join(quoted, ", "))
|
||||
}
|
||||
if m.Plugin.Private {
|
||||
b.WriteString("private = true\n")
|
||||
}
|
||||
if m.Compatibility != nil {
|
||||
b.WriteString("\n[compatibility]\n")
|
||||
b.WriteString(fmt.Sprintf("block_core = %q\n", m.Compatibility.BlockCore))
|
||||
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)
|
||||
}
|
||||
for _, r := range m.Requires {
|
||||
b.WriteString("\n[[requires]]\n")
|
||||
b.WriteString(fmt.Sprintf("name = %q\n", r.Name))
|
||||
b.WriteString(fmt.Sprintf("version = %q\n", r.Version))
|
||||
fmt.Fprintf(&b, "name = %q\n", r.Name)
|
||||
fmt.Fprintf(&b, "version = %q\n", r.Version)
|
||||
}
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
@ -1044,7 +1110,7 @@ func gitignoredTrackedWarning(repoDir string, w io.Writer) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
|
||||
for _, n := range strings.Split(names, "\n") {
|
||||
for n := range strings.SplitSeq(names, "\n") {
|
||||
fmt.Fprintln(w, " "+n)
|
||||
}
|
||||
fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
|
||||
@ -1063,7 +1129,7 @@ func untrackedFilesWarning(repoDir string, w io.Writer) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
|
||||
for _, n := range strings.Split(names, "\n") {
|
||||
for n := range strings.SplitSeq(names, "\n") {
|
||||
fmt.Fprintln(w, " "+n)
|
||||
}
|
||||
fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
|
||||
|
||||
165
cmd/ninja/cmd/plugin_tags.go
Normal file
165
cmd/ninja/cmd/plugin_tags.go
Normal file
@ -0,0 +1,165 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
core "git.dev.alexdunmow.com/block/core/plugin"
|
||||
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
|
||||
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
|
||||
)
|
||||
|
||||
func newPluginTagsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "tags",
|
||||
Short: "Show current tags and popular tags from the registry",
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
mod, err := readLocalMod()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(mod.Plugin.Tags) == 0 {
|
||||
fmt.Println("Current tags: (none)")
|
||||
} else {
|
||||
fmt.Printf("Current tags: %s\n", strings.Join(mod.Plugin.Tags, ", "))
|
||||
}
|
||||
|
||||
host, _ := c.Flags().GetString("host")
|
||||
cr, err := creds.Load()
|
||||
if err != nil {
|
||||
return nil // not signed in is fine — silent best-effort
|
||||
}
|
||||
resolvedHost, hc, err := cr.Resolve(host)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cli := orchclient.New(resolvedHost, hc.Token)
|
||||
line := fetchPopularTagsForList(cli, mod.Plugin.Kind)
|
||||
fmt.Println(line)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "add <tag>...",
|
||||
Short: "Add tags to the local plugin.mod (union with current)",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("add", args) },
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "rm <tag>...",
|
||||
Short: "Remove tags from the local plugin.mod",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("rm", args) },
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "set <tag>...",
|
||||
Short: "Replace all tags in the local plugin.mod",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("set", args) },
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "Remove all tags from the local plugin.mod",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error { return mutateTags("clear", nil) },
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// mutateTags reads plugin.mod, computes the new tag set, normalises, and writes
|
||||
// it back. Prints the before→after diff and a reminder to publish.
|
||||
func mutateTags(op string, args []string) error {
|
||||
mod, err := readLocalMod()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := append([]string(nil), mod.Plugin.Tags...)
|
||||
|
||||
var next []string
|
||||
switch op {
|
||||
case "add":
|
||||
next = append(append([]string(nil), before...), args...)
|
||||
case "rm":
|
||||
drop := map[string]struct{}{}
|
||||
for _, a := range args {
|
||||
drop[strings.ToLower(strings.TrimSpace(a))] = struct{}{}
|
||||
}
|
||||
for _, t := range before {
|
||||
if _, gone := drop[t]; !gone {
|
||||
next = append(next, t)
|
||||
}
|
||||
}
|
||||
case "set":
|
||||
next = append([]string(nil), args...)
|
||||
case "clear":
|
||||
next = nil
|
||||
default:
|
||||
return fmt.Errorf("unknown tag op: %s", op)
|
||||
}
|
||||
|
||||
normalised, err := core.NormalizeTags(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeLocalModTags(mod, normalised); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sortedBefore := append([]string(nil), before...)
|
||||
sortedAfter := append([]string(nil), normalised...)
|
||||
sort.Strings(sortedBefore)
|
||||
sort.Strings(sortedAfter)
|
||||
fmt.Printf("Tags: [%s] → [%s]\n", strings.Join(sortedBefore, ", "), strings.Join(sortedAfter, ", "))
|
||||
if !slices.Equal(sortedBefore, sortedAfter) {
|
||||
fmt.Println("Run 'ninja plugin publish' to push to the registry.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readLocalMod() (*core.ModFile, error) {
|
||||
b, err := os.ReadFile("plugin.mod")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read plugin.mod: %w", err)
|
||||
}
|
||||
mod, err := core.ParseModFull(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse plugin.mod: %w", err)
|
||||
}
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
// writeLocalModTags rewrites plugin.mod with the new tag set, preserving all
|
||||
// other fields by reusing upsertPluginMod.
|
||||
func writeLocalModTags(mod *core.ModFile, tags []string) error {
|
||||
return upsertPluginMod(
|
||||
mod.Plugin.Scope,
|
||||
mod.Plugin.Name,
|
||||
mod.Plugin.DisplayName,
|
||||
mod.Plugin.Description,
|
||||
mod.Plugin.Kind,
|
||||
mod.Plugin.Categories,
|
||||
tags,
|
||||
mod.Plugin.Private,
|
||||
)
|
||||
}
|
||||
|
||||
// fetchPopularTagsForList returns a single user-facing line listing the most-used
|
||||
// tags for the given kind. The current body returns an unavailable-notice
|
||||
// because core's copy of the orchestrator proto bindings does not yet expose
|
||||
// ListTags; once bindings are regenerated this body becomes a real
|
||||
// PluginRegistryService.ListTags call rendering "Popular: tag (count), ..." or
|
||||
// "Popular tags: (none yet)".
|
||||
func fetchPopularTagsForList(cli *orchclient.Client, kind string) string {
|
||||
_ = cli
|
||||
_ = kind
|
||||
return "(popular tags unavailable — orchestrator bindings not yet regenerated)"
|
||||
}
|
||||
|
||||
@ -548,6 +548,78 @@ func TestParsePrivateCoord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutateTags_AddRmSetClear(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed plugin.mod with no tags.
|
||||
must(upsertPluginMod("themes", "darkpro", "Dark Pro", "Sleek dark theme", "theme", []string{}, nil, false))
|
||||
|
||||
// add
|
||||
must(mutateTags("add", []string{"dark", "agency"}))
|
||||
mod, err := readLocalMod()
|
||||
must(err)
|
||||
if len(mod.Plugin.Tags) != 2 || mod.Plugin.Tags[0] != "dark" || mod.Plugin.Tags[1] != "agency" {
|
||||
t.Errorf("after add: %v", mod.Plugin.Tags)
|
||||
}
|
||||
|
||||
// add (dedupe + normalise)
|
||||
must(mutateTags("add", []string{"Agency", "Serif"}))
|
||||
mod, _ = readLocalMod()
|
||||
if len(mod.Plugin.Tags) != 3 {
|
||||
t.Errorf("after dedupe add: %v", mod.Plugin.Tags)
|
||||
}
|
||||
|
||||
// rm
|
||||
must(mutateTags("rm", []string{"dark"}))
|
||||
mod, _ = readLocalMod()
|
||||
for _, tag := range mod.Plugin.Tags {
|
||||
if tag == "dark" {
|
||||
t.Errorf("after rm: dark still present: %v", mod.Plugin.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// set
|
||||
must(mutateTags("set", []string{"editorial"}))
|
||||
mod, _ = readLocalMod()
|
||||
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "editorial" {
|
||||
t.Errorf("after set: %v", mod.Plugin.Tags)
|
||||
}
|
||||
|
||||
// clear
|
||||
must(mutateTags("clear", nil))
|
||||
mod, _ = readLocalMod()
|
||||
if len(mod.Plugin.Tags) != 0 {
|
||||
t.Errorf("after clear: %v", mod.Plugin.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutateTags_RejectsInvalidNoWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
if err := upsertPluginMod("themes", "x", "X", "", "theme", nil, []string{"dark"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mutateTags("add", []string{"BAD SPACE"}); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
mod, err := readLocalMod()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "dark" {
|
||||
t.Errorf("tags mutated despite error: %v", mod.Plugin.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tomlpkg "github.com/BurntSushi/toml"
|
||||
@ -30,6 +32,7 @@ type ModPlugin struct {
|
||||
Version string `toml:"version"`
|
||||
Kind string `toml:"kind,omitempty"`
|
||||
Categories []string `toml:"categories,omitempty"`
|
||||
Tags []string `toml:"tags,omitempty"`
|
||||
// RequiredIconPacks names icon-pack slugs the host CMS must ensure are
|
||||
// installed before the plugin is loaded (e.g. "tabler", "phosphor"). The
|
||||
// standalone-plugin loader honours this best-effort by auto-installing any
|
||||
@ -86,3 +89,54 @@ func (m *ModFile) Coords() string {
|
||||
// live. Coords for private plugins resolve to "@private/<name>@<version>";
|
||||
// uniqueness is enforced by (owner_account_id, name), not by the slug.
|
||||
const PrivateScopeSlug = "private"
|
||||
|
||||
const (
|
||||
TagMinLen = 2
|
||||
TagMaxLen = 30
|
||||
TagMaxCount = 10
|
||||
)
|
||||
|
||||
// tagSlugRe matches lowercase a-z, 0-9, with single hyphens between groups.
|
||||
// Rejects leading/trailing/consecutive hyphens.
|
||||
var tagSlugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
// NormalizeTags trims, lowercases, dedupes (case-insensitively), validates,
|
||||
// and caps a slice of tags. Returns the cleaned slice or an error listing
|
||||
// every offending input so authors fix them in one pass.
|
||||
//
|
||||
// Rules:
|
||||
// - trim surrounding whitespace; drop empty entries silently
|
||||
// - lowercase
|
||||
// - require [a-z0-9-]{TagMinLen..TagMaxLen}, no leading/trailing/consecutive hyphens
|
||||
// - dedupe case-insensitively, preserving first occurrence order
|
||||
// - at most TagMaxCount entries (counted after dedupe)
|
||||
func NormalizeTags(in []string) ([]string, error) {
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
var bad []string
|
||||
for _, raw := range in {
|
||||
t := strings.ToLower(strings.TrimSpace(raw))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[t]; dup {
|
||||
continue
|
||||
}
|
||||
if len(t) < TagMinLen || len(t) > TagMaxLen || !tagSlugRe.MatchString(t) {
|
||||
bad = append(bad, raw)
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid tags (must be %d-%d chars, lowercase a-z 0-9 and single hyphens, no leading/trailing hyphen): %s",
|
||||
TagMinLen, TagMaxLen, strings.Join(bad, ", "),
|
||||
)
|
||||
}
|
||||
if len(out) > TagMaxCount {
|
||||
return nil, fmt.Errorf("too many tags: got %d, max %d", len(out), TagMaxCount)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -271,3 +273,131 @@ version = ">=1.2"
|
||||
t.Errorf("Requires[1].Version = %q", m.Requires[1].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_HappyPath(t *testing.T) {
|
||||
got, err := NormalizeTags([]string{"dark", "agency", "serif"})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeTags err: %v", err)
|
||||
}
|
||||
want := []string{"dark", "agency", "serif"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_LowercaseAndTrim(t *testing.T) {
|
||||
got, err := NormalizeTags([]string{" Dark ", "AGENCY"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "dark" || got[1] != "agency" {
|
||||
t.Errorf("got %v, want [dark agency]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_DedupesCaseInsensitive(t *testing.T) {
|
||||
got, err := NormalizeTags([]string{"dark", "Dark", "DARK", "agency"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "dark" || got[1] != "agency" {
|
||||
t.Errorf("got %v, want [dark agency]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_DropsEmpty(t *testing.T) {
|
||||
got, err := NormalizeTags([]string{"", "dark", " "})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "dark" {
|
||||
t.Errorf("got %v, want [dark]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_RejectsBadSlugs(t *testing.T) {
|
||||
_, err := NormalizeTags([]string{"valid", "Has Space", "trailing-", "-leading", "double--hyphen", "a", strings.Repeat("x", 31)})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
for _, frag := range []string{"Has Space", "trailing-", "-leading", "double--hyphen", "a", strings.Repeat("x", 31)} {
|
||||
if !strings.Contains(err.Error(), frag) {
|
||||
t.Errorf("error %q does not mention %q", err.Error(), frag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_AcceptsBounds(t *testing.T) {
|
||||
got, err := NormalizeTags([]string{"ab", strings.Repeat("a", 30)})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %v, want both accepted", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_CapEnforced(t *testing.T) {
|
||||
in := make([]string, TagMaxCount+1)
|
||||
for i := range in {
|
||||
in[i] = fmt.Sprintf("tag-%d", i)
|
||||
}
|
||||
_, err := NormalizeTags(in)
|
||||
if err == nil {
|
||||
t.Fatal("expected too-many-tags error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many tags") {
|
||||
t.Errorf("error %q does not mention 'too many tags'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTags_NilInput(t *testing.T) {
|
||||
got, err := NormalizeTags(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModFull_Tags(t *testing.T) {
|
||||
src := []byte(`
|
||||
[plugin]
|
||||
name = "dark-pro"
|
||||
scope = "themes"
|
||||
version = "0.1.0"
|
||||
kind = "theme"
|
||||
tags = ["dark", "agency", "serif"]
|
||||
`)
|
||||
m, err := ParseModFull(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModFull err: %v", err)
|
||||
}
|
||||
if len(m.Plugin.Tags) != 3 {
|
||||
t.Fatalf("Tags len = %d, want 3 (%v)", len(m.Plugin.Tags), m.Plugin.Tags)
|
||||
}
|
||||
if m.Plugin.Tags[0] != "dark" || m.Plugin.Tags[1] != "agency" || m.Plugin.Tags[2] != "serif" {
|
||||
t.Errorf("Tags = %v", m.Plugin.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModFull_TagsOmittedIsNil(t *testing.T) {
|
||||
src := []byte(`
|
||||
[plugin]
|
||||
name = "no-tags"
|
||||
version = "0.1.0"
|
||||
`)
|
||||
m, err := ParseModFull(src)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if m.Plugin.Tags != nil {
|
||||
t.Errorf("Tags = %v, want nil when omitted", m.Plugin.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user