Compare commits

...

8 Commits

Author SHA1 Message Date
Alex Dunmow
bb3ddfe1bd refactor(ninja): extract popular-tags stubs into named helpers 2026-06-07 15:38:24 +08:00
Alex Dunmow
5d368da839 test(ninja): plugin tags subcommand round-trip
Adds TestMutateTags_AddRmSetClear (covers add/rm/set/clear operations
including dedupe and normalisation) and TestMutateTags_RejectsInvalidNoWrite
(ensures validation failures don't mutate plugin.mod).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:34:36 +08:00
Alex Dunmow
e16655aed8 refactor(ninja): use slices.Equal in mutateTags 2026-06-07 15:33:38 +08:00
Alex Dunmow
03d32aba26 feat(ninja): add 'plugin tags' subcommand
Adds ninja plugin tags (show), add, rm, set, and clear subcommands that
read and mutate the local plugin.mod tag list via NormalizeTags. The bare
tags command stubs ListTags with a TODO(tags): marker for Task 12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:31:36 +08:00
Alex Dunmow
533632a3bb feat(ninja): prompt for tags in plugin init/edit flow
Add promptTagsWithDefault helper (mirrors promptCategoriesWithDefault) and
wire it into the init flow so upsertPluginMod receives real user-entered tags
instead of nil. ListTags RPC stub left with TODO(tags) marker for Task 12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:27:30 +08:00
Alex Dunmow
6bc0f98979 refactor(ninja): thread tags through upsertPluginMod
Extend upsertPluginMod signature to accept tags parameter (positional arg 7,
between categories and private). Update the single call site to pass nil.
Add tags serialization in writeMod, mirroring the categories pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:25:00 +08:00
Alex Dunmow
d53c3d8325 feat(plugin): add Tags field to ModPlugin
TDD approach: added failing tests in mod_test.go that check parsing and
null-coalescing of tags, then added the []string Tags field to ModPlugin
struct with TOML tag "tags,omitempty".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:23:05 +08:00
Alex Dunmow
ed365f9030 feat(plugin): add NormalizeTags helper and slug rules
Add NormalizeTags, TagMinLen, TagMaxLen, TagMaxCount constants and
tagSlugRe to plugin/mod.go. Full TDD: 8 tests covering happy path,
trim/lowercase, case-insensitive dedupe, empty-drop, bad-slug rejection,
boundary acceptance, cap enforcement, and nil input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:20:24 +08:00
5 changed files with 507 additions and 20 deletions

View File

@ -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)")

View 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)"
}

View File

@ -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...)

View File

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

View File

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