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>
This commit is contained in:
parent
ba87684696
commit
ed365f9030
@ -1,6 +1,8 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tomlpkg "github.com/BurntSushi/toml"
|
tomlpkg "github.com/BurntSushi/toml"
|
||||||
@ -86,3 +88,54 @@ func (m *ModFile) Coords() string {
|
|||||||
// live. Coords for private plugins resolve to "@private/<name>@<version>";
|
// live. Coords for private plugins resolve to "@private/<name>@<version>";
|
||||||
// uniqueness is enforced by (owner_account_id, name), not by the slug.
|
// uniqueness is enforced by (owner_account_id, name), not by the slug.
|
||||||
const PrivateScopeSlug = "private"
|
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
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -271,3 +273,95 @@ version = ">=1.2"
|
|||||||
t.Errorf("Requires[1].Version = %q", m.Requires[1].Version)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user