]> go.fuhry.dev Git - runtime.git/commitdiff
stringmatch: support serialization, add tests, etc.
authorDan Fuhry <dan@fuhry.com>
Sat, 22 Mar 2025 01:11:51 +0000 (21:11 -0400)
committerDan Fuhry <dan@fuhry.com>
Sat, 22 Mar 2025 01:11:51 +0000 (21:11 -0400)
utils/slices2/map.go [new file with mode: 0644]
utils/stringmatch/matchers.go [moved from utils/stringmatch/stringmatch.go with 58% similarity]
utils/stringmatch/matchers_test.go [new file with mode: 0644]
utils/stringmatch/serialization.go [new file with mode: 0644]
utils/stringmatch/serialization_test.go [new file with mode: 0644]

diff --git a/utils/slices2/map.go b/utils/slices2/map.go
new file mode 100644 (file)
index 0000000..cae39a7
--- /dev/null
@@ -0,0 +1,9 @@
+package slices2
+
+func Map[S any, V any](in []S, transform func(S) V) []V {
+       out := make([]V, len(in))
+       for i, val := range in {
+               out[i] = transform(val)
+       }
+       return out
+}
similarity index 58%
rename from utils/stringmatch/stringmatch.go
rename to utils/stringmatch/matchers.go
index 3a7a83fa43d8907b3f7c4b36c597735dbb36795d..20ebe25688fb587f9bf8089df5ca04b5ec1aa023 100644 (file)
@@ -1,12 +1,16 @@
 package stringmatch
 
 import (
+       "fmt"
        "regexp"
        "strings"
+
+       "go.fuhry.dev/runtime/utils/slices2"
 )
 
 type StringMatcher interface {
        Match(input string) bool
+       String() string
 }
 
 type Prefix string
@@ -19,23 +23,43 @@ func (s Prefix) Match(input string) bool {
        return strings.HasPrefix(input, string(s))
 }
 
+func (s Prefix) String() string {
+       return fmt.Sprintf("%T(%s)", s, string(s))
+}
+
 func (s Suffix) Match(input string) bool {
        return strings.HasSuffix(input, string(s))
 }
 
+func (s Suffix) String() string {
+       return fmt.Sprintf("%T(%s)", s, string(s))
+}
+
 func (s Exact) Match(input string) bool {
        return input == string(s)
 }
 
+func (s Exact) String() string {
+       return fmt.Sprintf("%T(%s)", s, string(s))
+}
+
 func (s Contains) Match(input string) bool {
        return strings.Contains(input, string(s))
 }
 
+func (s Contains) String() string {
+       return fmt.Sprintf("%T(%s)", s, string(s))
+}
+
 func (s Regexp) Match(input string) bool {
        re := regexp.MustCompile(string(s))
        return re.MatchString(input)
 }
 
+func (s Regexp) String() string {
+       return fmt.Sprintf("%T(%s)", s, string(s))
+}
+
 type andMatcher struct {
        matchers []StringMatcher
 }
@@ -50,6 +74,14 @@ func (mm *andMatcher) Match(input string) bool {
        return true
 }
 
+func (mm *andMatcher) String() string {
+       matcherStrs := slices2.Map(
+               mm.matchers,
+               func(sm StringMatcher) string { return sm.String() },
+       )
+       return fmt.Sprintf("And(%s)", strings.Join(matcherStrs, " & "))
+}
+
 func And(matchers ...StringMatcher) StringMatcher {
        return &andMatcher{
                matchers: matchers,
@@ -70,6 +102,14 @@ func (mm *orMatcher) Match(input string) bool {
        return false
 }
 
+func (mm *orMatcher) String() string {
+       matcherStrs := slices2.Map(
+               mm.matchers,
+               func(sm StringMatcher) string { return sm.String() },
+       )
+       return fmt.Sprintf("Or(%s)", strings.Join(matcherStrs, " & "))
+}
+
 func Or(matchers ...StringMatcher) StringMatcher {
        return &orMatcher{
                matchers: matchers,
diff --git a/utils/stringmatch/matchers_test.go b/utils/stringmatch/matchers_test.go
new file mode 100644 (file)
index 0000000..3663a57
--- /dev/null
@@ -0,0 +1,98 @@
+package stringmatch
+
+import (
+       "fmt"
+       "os"
+       "testing"
+)
+
+type testCase struct {
+       matcher StringMatcher
+       input   string
+       expect  bool
+}
+
+func (tc *testCase) Run(t *testing.T) {
+       if tc.matcher.Match(tc.input) != tc.expect {
+               fmt.Fprintf(os.Stderr, "FAILED: %s on %q: expect: %t, got %t\n",
+                       tc.matcher.String(), tc.input, tc.expect, !tc.expect)
+               t.Fail()
+       }
+}
+
+func TestPrefix(t *testing.T) {
+       cases := []*testCase{
+               {Prefix("/foo"), "/foo/bar", true},
+               {Prefix("/foo"), "/bar", false},
+       }
+
+       for _, tc := range cases {
+               tc.Run(t)
+       }
+}
+
+func TestSuffix(t *testing.T) {
+       cases := []*testCase{
+               {Suffix("/foo"), "/foo/bar", false},
+               {Suffix("/bar"), "/bar", true},
+       }
+
+       for _, tc := range cases {
+               tc.Run(t)
+       }
+}
+
+func TestExact(t *testing.T) {
+       cases := []*testCase{
+               {Exact("/foo"), "/foo", true},
+               {Exact("/foo"), "/bar", false},
+       }
+
+       for _, tc := range cases {
+               tc.Run(t)
+       }
+}
+
+func TestContains(t *testing.T) {
+       cases := []*testCase{
+               {Contains("o"), "/foo", true},
+               {Contains("a"), "/foo", false},
+       }
+
+       for _, tc := range cases {
+               tc.Run(t)
+       }
+}
+
+func TestRegexp(t *testing.T) {
+       cases := []*testCase{
+               {Regexp("^/foo"), "/foo/bar", true},
+               {Regexp("/foo$"), "/bar", false},
+       }
+
+       for _, tc := range cases {
+               tc.Run(t)
+       }
+}
+
+func TestAnd(t *testing.T) {
+       cases := []*testCase{
+               {And(Prefix("/foo"), Suffix("/bar")), "/foo/bar", true},
+               {And(Prefix("/foo"), Suffix("/baz")), "/foo/bar", false},
+       }
+
+       for _, tc := range cases {
+               tc.Run(t)
+       }
+}
+
+func TestOr(t *testing.T) {
+       cases := []*testCase{
+               {Or(Prefix("/foo"), Suffix("/bar")), "/foo/bar", true},
+               {Or(Contains("/baz"), Contains("/quux")), "/foo/bar", false},
+       }
+
+       for _, tc := range cases {
+               tc.Run(t)
+       }
+}
diff --git a/utils/stringmatch/serialization.go b/utils/stringmatch/serialization.go
new file mode 100644 (file)
index 0000000..83935a1
--- /dev/null
@@ -0,0 +1,100 @@
+package stringmatch
+
+import (
+       "errors"
+       "fmt"
+       "strings"
+)
+
+type MatchRule struct {
+       Mode  string       `yaml:"mode" json:"mode"`
+       Value string       `yaml:"value" json:"value"`
+       Rules []*MatchRule `yaml:"rules" json:"rules"`
+}
+
+func (m *MatchRule) Matcher() (StringMatcher, error) {
+       switch strings.ToLower(m.Mode) {
+       case "prefix", "startswith":
+               if m.Value == "" {
+                       return nil, errors.New("prefix matcher is missing value")
+               }
+               if len(m.Rules) != 0 {
+                       return nil, errors.New("prefix matcher may not have sub-rules")
+               }
+               return Prefix(m.Value), nil
+       case "suffix", "endswith":
+               if m.Value == "" {
+                       return nil, errors.New("suffix matcher is missing value")
+               }
+               if len(m.Rules) != 0 {
+                       return nil, errors.New("suffix matcher may not have sub-rules")
+               }
+               return Suffix(m.Value), nil
+       case "exact", "equals", "eq":
+               if m.Value == "" {
+                       return nil, errors.New("exact matcher is missing value")
+               }
+               if len(m.Rules) != 0 {
+                       return nil, errors.New("exact matcher may not have sub-rules")
+               }
+               return Exact(m.Value), nil
+       case "contains", "in":
+               if m.Value == "" {
+                       return nil, errors.New("contains matcher is missing value")
+               }
+               if len(m.Rules) != 0 {
+                       return nil, errors.New("contains matcher may not have sub-rules")
+               }
+               return Contains(m.Value), nil
+       case "regexp", "regex":
+               if m.Value == "" {
+                       return nil, errors.New("regexp matcher is missing value")
+               }
+               if len(m.Rules) != 0 {
+                       return nil, errors.New("regexp matcher may not have sub-rules")
+               }
+               return Regexp(m.Value), nil
+       case "and":
+               if m.Value != "" {
+                       return nil, errors.New("\"and\" matcher may not have a unary value")
+               }
+               if len(m.Rules) < 1 {
+                       return nil, errors.New("no rules present in \"and\" matcher")
+               }
+               and := &andMatcher{}
+               for i, r := range m.Rules {
+                       matcher, err := r.Matcher()
+                       if err != nil {
+                               return nil, fmt.Errorf("while processing rule %d: %v", i, err)
+                       }
+                       and.matchers = append(and.matchers, matcher)
+               }
+               return and, nil
+       case "or":
+               if m.Value != "" {
+                       return nil, errors.New("\"or\" matcher may not have a unary value")
+               }
+               if len(m.Rules) < 1 {
+                       return nil, errors.New("no rules present in \"or\" matcher")
+               }
+               or := &orMatcher{}
+               for i, r := range m.Rules {
+                       matcher, err := r.Matcher()
+                       if err != nil {
+                               return nil, fmt.Errorf("while processing rule %d: %v", i, err)
+                       }
+                       or.matchers = append(or.matchers, matcher)
+               }
+               return or, nil
+       }
+
+       return nil, fmt.Errorf("unknown matching mode: %q", m.Mode)
+}
+
+func (m *MatchRule) Match(s string) bool {
+       matcher, err := m.Matcher()
+       if err != nil {
+               panic(err)
+       }
+       return matcher.Match(s)
+}
diff --git a/utils/stringmatch/serialization_test.go b/utils/stringmatch/serialization_test.go
new file mode 100644 (file)
index 0000000..9b7f251
--- /dev/null
@@ -0,0 +1,161 @@
+package stringmatch
+
+import (
+       "fmt"
+       "os"
+       "strings"
+       "testing"
+
+       "encoding/json"
+
+       "gopkg.in/yaml.v3"
+)
+
+type serializationTestCase struct {
+       json      string
+       yaml      string
+       expect    StringMatcher
+       expectErr StringMatcher
+}
+
+func (tc *serializationTestCase) Test(t *testing.T) {
+       if tc.json != "" {
+               tc.testJson(t)
+       } else if tc.yaml != "" {
+               tc.testYaml(t)
+       } else {
+               fmt.Fprintf(os.Stderr, "test case json or yaml is unset\n")
+               t.FailNow()
+       }
+}
+
+func (tc *serializationTestCase) testJson(t *testing.T) {
+       rule := &MatchRule{}
+       err := json.Unmarshal([]byte(strings.TrimSpace(tc.json)), rule)
+       if err != nil {
+               fmt.Fprintf(os.Stderr, "json unmarshal failed for %q: %v\n", tc.json, err)
+               t.Fail()
+               return
+       }
+
+       tc.testRule(t, rule)
+}
+
+func (tc *serializationTestCase) testYaml(t *testing.T) {
+       rule := &MatchRule{}
+       err := yaml.Unmarshal([]byte(strings.TrimSpace(tc.yaml)), rule)
+       if err != nil {
+               fmt.Fprintf(os.Stderr, "yaml unmarshal failed for %q: %v\n", tc.json, err)
+               t.Fail()
+               return
+       }
+
+       tc.testRule(t, rule)
+}
+
+func (tc *serializationTestCase) testRule(t *testing.T, rule *MatchRule) {
+       matcher, err := rule.Matcher()
+       if err != nil {
+               if tc.expectErr == nil {
+                       fmt.Fprintf(
+                               os.Stderr,
+                               "serialization rule %q matcher compilation unexpectedly failed: %v\n",
+                               tc.json, err)
+
+                       t.Fail()
+                       return
+               }
+
+               if !tc.expectErr.Match(err.Error()) {
+                       fmt.Fprintf(
+                               os.Stderr,
+                               "serialization rule %q matcher compilation failed with an unexpected error: %v\n",
+                               tc.json, err)
+
+                       t.Fail()
+                       return
+               }
+
+               // error matches expected error
+               return
+       }
+
+       if matcher.String() != tc.expect.String() {
+               fmt.Fprintf(
+                       os.Stderr,
+                       "json serialization rule %q matcher compilation failed with an unexpected error: %v\n",
+                       tc.json, err)
+
+               t.Fail()
+       }
+}
+
+func TestJson(t *testing.T) {
+       testCases := []*serializationTestCase{
+               {
+                       json:   `{"mode":"prefix","value":"test"}`,
+                       expect: Prefix("test"),
+               },
+               {
+                       json:      `{"mode":"prefix","value":""}`,
+                       expectErr: Exact("prefix matcher is missing value"),
+               },
+               {
+                       json:      `{"mode":"prefix","value":"x","rules":[{"mode":"exact"}]}`,
+                       expectErr: Exact("prefix matcher may not have sub-rules"),
+               },
+               {
+                       json: `{
+                               "mode":"and",
+                               "rules":[
+                                       {"mode":"contains","value":"a"},
+                                       {"mode":"contains","value":"b"}
+                               ]}`,
+                       expect: And(Contains("a"), Contains("b")),
+               },
+       }
+
+       for _, tc := range testCases {
+               tc.Test(t)
+       }
+}
+
+func TestYaml(t *testing.T) {
+       testCases := []*serializationTestCase{
+               {
+                       yaml: `---
+mode: prefix
+value: test
+`,
+                       expect: Prefix("test"),
+               },
+               {
+                       yaml: `---
+mode: prefix
+value: ""`,
+                       expectErr: Exact("prefix matcher is missing value"),
+               },
+               {
+                       yaml: `---
+mode: prefix
+value: x
+rules:
+  - mode: exact`,
+                       expectErr: Exact("prefix matcher may not have sub-rules"),
+               },
+               {
+                       yaml: `---
+mode: and
+rules:
+  - mode: contains
+    value: a
+  - mode: contains
+    value: b`,
+                       expect: And(Contains("a"), Contains("b")),
+               },
+       }
+
+       for _, tc := range testCases {
+               tc.Test(t)
+       }
+}