From ed365f9030aee053c70c0c33311aa81334581292 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sun, 7 Jun 2026 15:20:24 +0800 Subject: [PATCH] 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 --- plugin/mod.go | 53 ++++++++++++++++++++++++++ plugin/mod_test.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/plugin/mod.go b/plugin/mod.go index adc0ca8..e95ea16 100644 --- a/plugin/mod.go +++ b/plugin/mod.go @@ -1,6 +1,8 @@ package plugin import ( + "fmt" + "regexp" "strings" tomlpkg "github.com/BurntSushi/toml" @@ -86,3 +88,54 @@ func (m *ModFile) Coords() string { // live. Coords for private plugins resolve to "@private/@"; // 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 +} diff --git a/plugin/mod_test.go b/plugin/mod_test.go index 798f4ec..b0de68b 100644 --- a/plugin/mod_test.go +++ b/plugin/mod_test.go @@ -1,6 +1,8 @@ package plugin import ( + "fmt" + "strings" "testing" ) @@ -271,3 +273,95 @@ 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) + } +}