Repository: mbrt/gmailctl (go)

Issue:
Add user config tests
Users should be able to define their tests. Example:

```jsonnet
{
  rules: [ /* rules */ ],
  tests: [
    {
      messages: [
        {
          subject: "Some message",
          to: "spammy-alerts@google.com",
          /* more fields */,
        },
        /* other messages */
      ],
      actions: {
        // expected effect of all actions from all matching rules
        labels: ['spammy-alerts'],
        archive: true,
      },
    },
    // more tests
  ],
}
```

To avoid re-implementing too much of gmail filters logic, we could restrict the checks to well known operators and "ban" the raw queries altogether.

We should also add a new `test` command in the `gmailctl` cli.

Pull request description:
Config test
Close #76.

Patch:
diff --git a/cmd/gmailctl/cmd/apply_cmd.go b/cmd/gmailctl/cmd/apply_cmd.go
index f203a18..d34a437 100644
--- a/cmd/gmailctl/cmd/apply_cmd.go
+++ b/cmd/gmailctl/cmd/apply_cmd.go
@@ -13,6 +13,7 @@ var (
 	applyFilename     string
 	applyYes          bool
 	applyRemoveLabels bool
+	applySkipTests    bool
 )
 
 const renameLabelWarning = `Warning: You are going to delete labels. This operation is
@@ -35,7 +36,7 @@ directory [config.(yaml|jsonnet)].`,
 		if f == "" {
 			f = configFilenameFromDir(cfgDir)
 		}
-		if err := apply(f, !applyYes); err != nil {
+		if err := apply(f, !applyYes, !applySkipTests); err != nil {
 			fatal(err)
 		}
 	}}
@@ -47,10 +48,11 @@ func init() {
 	applyCmd.PersistentFlags().StringVarP(&applyFilename, "filename", "f", "", "configuration file")
 	applyCmd.Flags().BoolVarP(&applyYes, "yes", "y", false, "don't ask for confirmation, just apply")
 	applyCmd.Flags().BoolVarP(&applyRemoveLabels, "remove-labels", "r", false, "allow removing labels")
+	applyCmd.Flags().BoolVarP(&applySkipTests, "yolo", "", false, "skip configuration tests")
 }
 
-func apply(path string, interactive bool) error {
-	parseRes, err := parseConfig(path, "")
+func apply(path string, interactive, test bool) error {
+	parseRes, err := parseConfig(path, "", test)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/gmailctl/cmd/config.go b/cmd/gmailctl/cmd/config.go
index 0c5990c..ffe54b7 100644
--- a/cmd/gmailctl/cmd/config.go
+++ b/cmd/gmailctl/cmd/config.go
@@ -7,6 +7,7 @@ import (
 	"github.com/pkg/errors"
 
 	papply "github.com/mbrt/gmailctl/pkg/apply"
+	"github.com/mbrt/gmailctl/pkg/cfgtest"
 	"github.com/mbrt/gmailctl/pkg/config"
 	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 )
@@ -24,7 +25,7 @@ func configFilenameFromDir(cfgDir string) string {
 	return path.Join(cfgDir, "config.jsonnet")
 }
 
-func parseConfig(path, originalPath string) (parseResult, error) {
+func parseConfig(path, originalPath string, test bool) (parseResult, error) {
 	var res parseResult
 	var err error
 
@@ -44,5 +45,22 @@ func parseConfig(path, originalPath string) (parseResult, error) {
 	}
 
 	res.Res, err = papply.FromConfig(res.Config)
+	if err != nil {
+		return res, err
+	}
+	if test && len(res.Config.Tests) > 0 {
+		ts, errs := cfgtest.NewFromParserRules(res.Res.Rules)
+		if len(errs) > 0 {
+			stderrPrintf("WARNING: %d filters are excluded from the tests:\n", len(errs))
+			for _, err := range errs {
+				stderrPrintf("  %v\n", err)
+			}
+		}
+		err = ts.ExecTests(res.Config.Tests)
+		if err != nil {
+			return res, errors.Wrap(err, "config tests failed")
+		}
+	}
+
 	return res, err
 }
diff --git a/cmd/gmailctl/cmd/debug_cmd.go b/cmd/gmailctl/cmd/debug_cmd.go
index e740396..5710ff3 100644
--- a/cmd/gmailctl/cmd/debug_cmd.go
+++ b/cmd/gmailctl/cmd/debug_cmd.go
@@ -44,7 +44,7 @@ func init() {
 }
 
 func debug(path string) error {
-	parseRes, err := parseConfig(path, "")
+	parseRes, err := parseConfig(path, "", false)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/gmailctl/cmd/diff_cmd.go b/cmd/gmailctl/cmd/diff_cmd.go
index 619cf97..ce0375a 100644
--- a/cmd/gmailctl/cmd/diff_cmd.go
+++ b/cmd/gmailctl/cmd/diff_cmd.go
@@ -41,7 +41,7 @@ func init() {
 }
 
 func diff(path string) error {
-	parseRes, err := parseConfig(path, "")
+	parseRes, err := parseConfig(path, "", false)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/gmailctl/cmd/edit_cmd.go b/cmd/gmailctl/cmd/edit_cmd.go
index a668909..3be4556 100644
--- a/cmd/gmailctl/cmd/edit_cmd.go
+++ b/cmd/gmailctl/cmd/edit_cmd.go
@@ -18,7 +18,8 @@ import (
 
 // Parameters
 var (
-	editFilename string
+	editFilename  string
+	editSkipTests bool
 )
 
 var (
@@ -56,7 +57,7 @@ directory [config.(yaml|jsonnet)].`,
 		if f == "" {
 			f = configFilenameFromDir(cfgDir)
 		}
-		if err := edit(f); err != nil {
+		if err := edit(f, !editSkipTests); err != nil {
 			fatal(err)
 		}
 	},
@@ -67,9 +68,10 @@ func init() {
 
 	// Flags and configuration settings
 	editCmd.PersistentFlags().StringVarP(&editFilename, "filename", "f", "", "configuration file")
+	editCmd.Flags().BoolVarP(&editSkipTests, "yolo", "", false, "skip configuration tests")
 }
 
-func edit(path string) error {
+func edit(path string, test bool) error {
 	// First make sure that Gmail can be contacted, so that we don't
 	// waste the user's time editing a config file that cannot be
 	// applied now.
@@ -91,7 +93,7 @@ func edit(path string) error {
 			_ = os.Remove(tmpPath)
 			return err
 		}
-		if err = applyEdited(tmpPath, path, gmailapi); err != nil {
+		if err = applyEdited(tmpPath, path, test, gmailapi); err != nil {
 			if errors.Cause(err) == errUnchanged {
 				// Unchanged, but move the file anyways (it could be a refactoring)
 				return moveFile(tmpPath, path)
@@ -182,8 +184,8 @@ func spawnEditor(path string) error {
 	return errors.New("no suitable editor found")
 }
 
-func applyEdited(path, originalPath string, gmailapi *api.GmailAPI) error {
-	parseRes, err := parseConfig(path, originalPath)
+func applyEdited(path, originalPath string, test bool, gmailapi *api.GmailAPI) error {
+	parseRes, err := parseConfig(path, originalPath, test)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/gmailctl/cmd/export_cmd.go b/cmd/gmailctl/cmd/export_cmd.go
index 3e1c95a..7aaef16 100644
--- a/cmd/gmailctl/cmd/export_cmd.go
+++ b/cmd/gmailctl/cmd/export_cmd.go
@@ -11,8 +11,9 @@ import (
 )
 
 var (
-	exportFilename string
-	exportOutput   string
+	exportFilename  string
+	exportOutput    string
+	exportSkipTests bool
 )
 
 // exportCmd represents the export command
@@ -30,7 +31,7 @@ directory [config.(yaml|jsonnet)].`,
 		if f == "" {
 			f = configFilenameFromDir(cfgDir)
 		}
-		if err := export(f, exportOutput); err != nil {
+		if err := export(f, exportOutput, !exportSkipTests); err != nil {
 			fatal(err)
 		}
 	},
@@ -42,9 +43,10 @@ func init() {
 	// Flags and configuration settings
 	exportCmd.PersistentFlags().StringVarP(&exportFilename, "filename", "f", "", "configuration file")
 	exportCmd.PersistentFlags().StringVarP(&exportOutput, "output", "o", "", "output file (defaut to stdout)")
+	exportCmd.Flags().BoolVarP(&exportSkipTests, "yolo", "", false, "skip configuration tests")
 }
 
-func export(inputPath, outputPath string) (err error) {
+func export(inputPath, outputPath string, test bool) (err error) {
 	var out io.Writer
 	if outputPath == "" {
 		out = os.Stdout
@@ -62,11 +64,11 @@ func export(inputPath, outputPath string) (err error) {
 		}()
 		out = f
 	}
-	return exportWithOut(inputPath, out)
+	return exportWithOut(inputPath, out, test)
 }
 
-func exportWithOut(path string, out io.Writer) error {
-	pres, err := parseConfig(path, "")
+func exportWithOut(path string, out io.Writer, test bool) error {
+	pres, err := parseConfig(path, "", test)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/gmailctl/cmd/root_cmd.go b/cmd/gmailctl/cmd/root_cmd.go
index 23ac187..f152122 100644
--- a/cmd/gmailctl/cmd/root_cmd.go
+++ b/cmd/gmailctl/cmd/root_cmd.go
@@ -20,8 +20,8 @@ var rootCmd = &cobra.Command{
 	Use:   "gmailctl",
 	Short: "Declarative configuration for Gmail",
 	Long: `Gmailctl is a command line utility that allows you to manage
-your Gmail filters in a declarative way, making it easier
-to maintain and to understand.`,
+your Gmail filters in a declarative way, making them easier
+to maintain and understand.`,
 }
 
 // Execute adds all child commands to the root command and sets flags appropriately.
diff --git a/pkg/config/v1alpha3/config.go b/pkg/config/v1alpha3/config.go
index 6eeb866..72de9d4 100644
--- a/pkg/config/v1alpha3/config.go
+++ b/pkg/config/v1alpha3/config.go
@@ -17,6 +17,7 @@ type Config struct {
 	Author  Author  `json:"author,omitempty"`
 	Labels  []Label `json:"labels,omitempty"`
 	Rules   []Rule  `json:"rules"`
+	Tests   []Test  `json:"tests,omitempty"`
 }
 
 // FilterNode represents a piece of a Gmail filter.
@@ -167,6 +168,25 @@ type LabelColor struct {
 	Text       string `json:"text"`
 }
 
+// Test represents the intended actions applied to a set of emails.
+type Test struct {
+	// Name is an optional name used for error reporting.
+	Name     string    `json:"name,omitempty"`
+	Messages []Message `json:"messages"`
+	Actions  Actions   `json:"actions"`
+}
+
+// Message represents the contents and metadata of an email.
+type Message struct {
+	From    string   `json:"from,omitempty"`
+	To      []string `json:"to,omitempty"`
+	Cc      []string `json:"cc,omitempty"`
+	Bcc     []string `json:"bcc,omitempty"`
+	Lists   []string `json:"lists,omitempty"`
+	Subject string   `json:"subject,omitempty"`
+	Body    string   `json:"body,omitempty"`
+}
+
 func jsonTagName(t reflect.StructTag) string {
 	return strings.Split(t.Get("json"), ",")[0]
 }
diff --git a/pkg/parser/ast.go b/pkg/parser/ast.go
index fcf9027..9d0cd65 100644
--- a/pkg/parser/ast.go
+++ b/pkg/parser/ast.go
@@ -79,6 +79,8 @@ type CriteriaAST interface {
 	RootFunction() FunctionType
 	// IsLeaf returns true if the root node is a leaf.
 	IsLeaf() bool
+	// AcceptVisitor implements the visitor pattern.
+	AcceptVisitor(v Visitor)
 }
 
 // Node is an AST node with children nodes. It can only be a logical operator.
@@ -102,6 +104,11 @@ func (n *Node) IsLeaf() bool {
 	return false
 }
 
+// AcceptVisitor implements the visitor pattern.
+func (n *Node) AcceptVisitor(v Visitor) {
+	v.VisitNode(n)
+}
+
 // Leaf is an AST node with no children.
 //
 // If the function has multiple arguments, they are grouped together with a
@@ -129,6 +136,17 @@ func (n *Leaf) IsLeaf() bool {
 	return true
 }
 
+// AcceptVisitor implements the visitor pattern.
+func (n *Leaf) AcceptVisitor(v Visitor) {
+	v.VisitLeaf(n)
+}
+
+// Visitor implements the visitor pattern for CriteriaAST.
+type Visitor interface {
+	VisitNode(n *Node)
+	VisitLeaf(n *Leaf)
+}
+
 // SimplifyCriteria applies multiple simplifications to a criteria.
 func SimplifyCriteria(tree CriteriaAST) (CriteriaAST, error) {
 	res, err := simplify(tree)
diff --git a/pkg/reporting/err.go b/pkg/reporting/err.go
new file mode 100644
index 0000000..7a8a966
--- /dev/null
+++ b/pkg/reporting/err.go
@@ -0,0 +1,23 @@
+package reporting
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+// Prettify returns a readable string representing the object.
+func Prettify(o interface{}, compact bool) string {
+	var (
+		b   []byte
+		err error
+	)
+	if compact {
+		b, err = json.Marshal(o)
+	} else {
+		b, err = json.MarshalIndent(o, "", "  ")
+	}
+	if err != nil {
+		return fmt.Sprintf("(invalid) %+v", o)
+	}
+	return string(b)
+}


Test patch:
diff --git a/cmd/gmailctl/cmd/test_cmd.go b/cmd/gmailctl/cmd/test_cmd.go
new file mode 100644
index 0000000..a6392c9
--- /dev/null
+++ b/cmd/gmailctl/cmd/test_cmd.go
@@ -0,0 +1,46 @@
+package cmd
+
+import "github.com/spf13/cobra"
+
+var testFilename string
+
+// testCmd represents the test command
+var testCmd = &cobra.Command{
+	Use:   "test",
+	Short: "Execute config tests",
+	Long: `Execute config tests.
+If the configuration file contains tests, they will be executed by
+this command. Not all filters constructs are supported; if any
+is found, the tests will ignore the entire filter and continue.
+This might result in imprecise testing.
+
+Warning: This command is still experimental.
+
+List of unsupported constructs:
+* Escaped expressions (pkg/config/v1alpha3/FilterNode.IsEscaped);
+* Raw queries (pkg/config/v1alpha3/FilterNode.Query).
+
+By default test uses the configuration file inside the config
+directory [config.(yaml|jsonnet)].`,
+	Run: func(cmd *cobra.Command, args []string) {
+		f := testFilename
+		if f == "" {
+			f = configFilenameFromDir(cfgDir)
+		}
+		if err := test(f); err != nil {
+			fatal(err)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(testCmd)
+
+	// Flags and configuration settings
+	testCmd.PersistentFlags().StringVarP(&testFilename, "filename", "f", "", "configuration file")
+}
+
+func test(path string) error {
+	_, err := parseConfig(path, "", true)
+	return err
+}
diff --git a/pkg/cfgtest/builder.go b/pkg/cfgtest/builder.go
new file mode 100644
index 0000000..2755d35
--- /dev/null
+++ b/pkg/cfgtest/builder.go
@@ -0,0 +1,189 @@
+package cfgtest
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/mbrt/gmailctl/pkg/parser"
+)
+
+// NewEvaluator creates a RuleEvaluator starting from a parser criteria.
+func NewEvaluator(criteria parser.CriteriaAST) (RuleEvaluator, error) {
+	v := evalBuilder{}
+	criteria.AcceptVisitor(&v)
+	return v.Res, v.Err
+}
+
+type evalBuilder struct {
+	Res RuleEvaluator
+	Err error
+}
+
+func (r *evalBuilder) VisitNode(n *parser.Node) {
+	var children []RuleEvaluator
+	for _, c := range n.Children {
+		ce, err := NewEvaluator(c)
+		if err != nil {
+			r.Err = err
+			return
+		}
+		children = append(children, ce)
+	}
+
+	switch n.Operation {
+	case parser.OperationAnd:
+		r.Res = andNode{children}
+	case parser.OperationOr:
+		r.Res = orNode{children}
+	case parser.OperationNot:
+		if len(children) != 1 {
+			r.Err = fmt.Errorf("unexpected children size for 'not' node: %d", len(children))
+		}
+		r.Res = notNode{children[0]}
+	default:
+		r.Err = fmt.Errorf("unsupported operation %s", n.Operation)
+	}
+}
+
+func (r *evalBuilder) VisitLeaf(n *parser.Leaf) {
+	if n.IsRaw {
+		r.Err = fmt.Errorf("unsupported 'raw query': %v", n)
+		return
+	}
+
+	var rules []RuleEvaluator
+
+	switch n.Function {
+	case parser.FunctionFrom, parser.FunctionCc, parser.FunctionBcc, parser.FunctionList:
+		rules = expandAll(n.Args, func(a string) RuleEvaluator {
+			return emailField(toMatchField(n.Function), a)
+		})
+	case parser.FunctionTo:
+		rules = expandAll(n.Args, expandTo)
+	case parser.FunctionSubject:
+		rules = expandAll(n.Args, func(a string) RuleEvaluator {
+			return freeTextField(matchFieldSubject, a)
+		})
+	case parser.FunctionHas:
+		rules = expandAll(n.Args, expandHas)
+	case parser.FunctionQuery:
+		r.Err = fmt.Errorf("unsupported unconstrained query: '%v'", n)
+		return
+	default:
+		r.Err = fmt.Errorf("unsupported function: %s", n.Function)
+		return
+	}
+
+	r.Res, r.Err = group(n.Grouping, rules)
+}
+
+// expandAll applies the given expander to all the arguments.
+func expandAll(args []string, f func(arg string) RuleEvaluator) []RuleEvaluator {
+	var res []RuleEvaluator
+	for _, arg := range args {
+		res = append(res, f(arg))
+	}
+	return res
+}
+
+// expandTo expands the 'to' function into the corresponding evaluators.
+//
+// In Gmail, 'to' is a shortcut for (to || cc || bcc || list).
+func expandTo(arg string) RuleEvaluator {
+	return orNode{
+		[]RuleEvaluator{
+			emailField(matchFieldTo, arg),
+			emailField(matchFieldCc, arg),
+			emailField(matchFieldBcc, arg),
+			emailField(matchFieldLists, arg),
+		},
+	}
+}
+
+// expandHas expands the 'has' operator into the corresponding evaluators.
+//
+// The 'has' operator basically matches every field.
+// In input you have a list of items, like "this", "two words", in output evaluators
+// that match them in any possible field (to, from, subject, body, ...).
+func expandHas(arg string) RuleEvaluator {
+	return orNode{
+		[]RuleEvaluator{
+			expandTo(arg),
+			emailField(matchFieldFrom, arg),
+			freeTextField(matchFieldSubject, arg),
+			freeTextField(matchFieldBody, arg),
+		},
+	}
+}
+
+func emailField(f matchField, arg string) RuleEvaluator {
+	// Gmail doesn't distinguish between @ and .
+	r := funcNode{
+		field:     f,
+		expected:  normalizeField(arg),
+		matchType: matchTypeExact,
+	}
+	// Asking for *@gmail.com or @gmail.com is the same and means
+	// match the suffix.
+	if strings.HasPrefix(r.expected, "*") {
+		r.expected = r.expected[1:]
+		r.matchType = matchTypeSuffix
+	} else if strings.HasPrefix(r.expected, ".") {
+		r.matchType = matchTypeSuffix
+	}
+	return r
+}
+
+func freeTextField(f matchField, arg string) RuleEvaluator {
+	return funcNode{
+		field:     f,
+		expected:  normalizeField(arg),
+		matchType: matchTypeContains,
+	}
+}
+
+// group returns an evaluator built by grouping together the given ones with
+// an operator.
+func group(op parser.OperationType, rs []RuleEvaluator) (RuleEvaluator, error) {
+	if len(rs) == 0 {
+		return nil, errors.New("empty children, cannot group")
+	}
+	if op != parser.OperationNot && len(rs) == 1 {
+		// No need for grouping.
+		return rs[0], nil
+	}
+
+	switch op {
+	case parser.OperationAnd:
+		return andNode{rs}, nil
+	case parser.OperationOr:
+		return orNode{rs}, nil
+	case parser.OperationNot:
+		if len(rs) != 1 {
+			return nil, fmt.Errorf("unexpected children size for 'not' node: %d", len(rs))
+		}
+		return notNode{rs[0]}, nil
+	default:
+		return nil, fmt.Errorf("unsupported operation %s", op)
+	}
+}
+
+func toMatchField(f parser.FunctionType) matchField {
+	switch f {
+	case parser.FunctionFrom:
+		return matchFieldFrom
+	case parser.FunctionTo:
+		return matchFieldTo
+	case parser.FunctionCc:
+		return matchFieldCc
+	case parser.FunctionBcc:
+		return matchFieldBcc
+	case parser.FunctionList:
+		return matchFieldLists
+	case parser.FunctionSubject:
+		return matchFieldSubject
+	default:
+		return matchFieldUnknown
+	}
+}
diff --git a/pkg/cfgtest/cfgtest.go b/pkg/cfgtest/cfgtest.go
new file mode 100644
index 0000000..958cd0c
--- /dev/null
+++ b/pkg/cfgtest/cfgtest.go
@@ -0,0 +1,211 @@
+package cfgtest
+
+import (
+	"fmt"
+	"sort"
+
+	cfg "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/gmail"
+	"github.com/mbrt/gmailctl/pkg/parser"
+	"github.com/mbrt/gmailctl/pkg/reporting"
+)
+
+// NewFromParserRules translates parser Rules into test Rules.
+//
+// This function is best effort. Every criteria that is not convertible is going
+// to be ignored and an error is returned in its place. The resulting rules will
+// contain only the valid rules.
+func NewFromParserRules(rs []parser.Rule) (Rules, []error) {
+	var res Rules
+	var errs []error
+
+	for i, pr := range rs {
+		re, err := NewEvaluator(pr.Criteria)
+		if err != nil {
+			errs = append(errs, fmt.Errorf(
+				"cannot evaluate criteria #%d: %w", i, err))
+		} else {
+			res = append(res, Rule{re, Actions(pr.Actions)})
+		}
+	}
+
+	return res, errs
+}
+
+// Rule represents a filter that can evaluate whether messages apply to it.
+type Rule struct {
+	Eval    RuleEvaluator
+	Actions Actions
+}
+
+// Rules is a set of rules.
+type Rules []Rule
+
+// ExecTests evaluates all the rules against the given tests.
+//
+// The evaluation stops at the first failing test.
+func (rs Rules) ExecTests(ts []cfg.Test) error {
+	for i, t := range ts {
+		if err := rs.ExecTest(t); err != nil {
+			name := t.Name
+			if name == "" {
+				name = fmt.Sprintf("#%d", i)
+			}
+			return fmt.Errorf("test '%s' failed: %w", name, err)
+		}
+	}
+	return nil
+}
+
+// ExecTest evaluates the rules on all the messages of the given test.
+//
+// If the rules apply as expected by the test, no error is returned.
+func (rs Rules) ExecTest(t cfg.Test) error {
+	for i, msg := range t.Messages {
+		expected, err := rs.MatchingActions(msg)
+		if err != nil {
+			return fmt.Errorf("message #%d: error evaluating matching filters: %w", i, err)
+		}
+		if !expected.Equal(Actions(t.Actions)) {
+			return fmt.Errorf("message #%d is going to get unexpected actions: %s", i, reporting.Prettify(expected, true))
+		}
+	}
+	return nil
+}
+
+// MatchingActions returns the actions that would be applied by the rules if
+// the given message arrived.
+//
+// An error can be returned if multiple incompatible actions would be applied. Note
+// that in Gmail this wouldn't be an error, but a nondeterministic action would be
+// applied. Since this situation is most likely a mistake by the user, we treat it
+// as an error.
+func (rs Rules) MatchingActions(msg cfg.Message) (Actions, error) {
+	var (
+		res Actions
+		err error
+	)
+	for _, rule := range rs {
+		if rule.Eval.Match(msg) {
+			if res, err = mergeActions(res, rule.Actions); err != nil {
+				return res, fmt.Errorf("conflicting filters detected: %w", err)
+			}
+		}
+	}
+	return res, nil
+}
+
+// Actions represent the actions applied by a filter.
+type Actions parser.Actions
+
+// Equal returns true if the given actions are equivalent to this object.
+func (a Actions) Equal(a2 Actions) bool {
+	if a.Archive != a2.Archive {
+		return false
+	}
+	if a.Delete != a2.Delete {
+		return false
+	}
+	if a.MarkRead != a2.MarkRead {
+		return false
+	}
+	if a.Star != a2.Star {
+		return false
+	}
+	if !triboolsEqual(a.MarkSpam, a2.MarkSpam) {
+		return false
+	}
+	if !triboolsEqual(a.MarkImportant, a2.MarkImportant) {
+		return false
+	}
+	if a.Category != a2.Category {
+		return false
+	}
+	if !stringSliceEqual(a.Labels, a2.Labels) {
+		return false
+	}
+	return a.Forward == a2.Forward
+}
+
+func mergeActions(a1, a2 Actions) (Actions, error) {
+	res := Actions{
+		Archive:  a1.Archive || a2.Archive,
+		Delete:   a1.Delete || a2.Delete,
+		MarkRead: a1.MarkRead || a2.MarkRead,
+		Star:     a1.Star || a2.Star,
+	}
+	var err error
+	if res.MarkSpam, err = mergeTribool(a1.MarkSpam, a2.MarkSpam); err != nil {
+		return res, fmt.Errorf("'markSpam' is applied differently: %w", err)
+	}
+	if res.MarkImportant, err = mergeTribool(a1.MarkImportant, a2.MarkImportant); err != nil {
+		return res, fmt.Errorf("'markImportant' is applied differently: %w", err)
+	}
+	if res.Category, err = mergeCategories(a1.Category, a2.Category); err != nil {
+		return res, fmt.Errorf("'category' is applied differently: %w", err)
+	}
+	res.Labels = append(res.Labels, a1.Labels...)
+	res.Labels = append(res.Labels, a2.Labels...)
+	if res.Forward, err = mergeStrings(a1.Forward, a2.Forward); err != nil {
+		return res, fmt.Errorf("'forward' is applied differently: %w", err)
+	}
+	return res, nil
+}
+
+func mergeTribool(b1, b2 *bool) (*bool, error) {
+	if b1 == nil {
+		return b2, nil
+	}
+	if b2 == nil {
+		return b1, nil
+	}
+	if *b1 != *b2 {
+		return nil, fmt.Errorf("got %v and %v", *b1, *b2)
+	}
+	return b1, nil
+}
+
+func mergeCategories(c1, c2 gmail.Category) (gmail.Category, error) {
+	r, err := mergeStrings(string(c1), string(c2))
+	return gmail.Category(r), err
+}
+
+func mergeStrings(s1, s2 string) (string, error) {
+	if s1 == "" {
+		return s2, nil
+	}
+	if s2 == "" {
+		return s1, nil
+	}
+	if s1 != s2 {
+		return "", fmt.Errorf("got %s and %s", s1, s2)
+	}
+	return s1, nil
+}
+
+func triboolsEqual(b1, b2 *bool) bool {
+	if b1 == nil && b2 == nil {
+		return true
+	}
+	if b1 == nil {
+		return b2 == nil
+	}
+	if b2 == nil {
+		return b1 == nil
+	}
+	return *b1 == *b2
+}
+
+func stringSliceEqual(s1, s2 []string) bool {
+	if len(s1) != len(s2) {
+		return false
+	}
+	sort.Strings(s1)
+	sort.Strings(s2)
+	for i := 0; i < len(s1); i++ {
+		if s1[i] != s2[i] {
+			return false
+		}
+	}
+	return true
+}
diff --git a/pkg/cfgtest/eval.go b/pkg/cfgtest/eval.go
new file mode 100644
index 0000000..6a9eb7e
--- /dev/null
+++ b/pkg/cfgtest/eval.go
@@ -0,0 +1,123 @@
+package cfgtest
+
+import (
+	"strings"
+
+	cfg "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+)
+
+type matchType int
+
+const (
+	matchTypeExact = iota
+	matchTypeSuffix
+	matchTypeContains
+)
+
+type matchField int
+
+const (
+	matchFieldUnknown = iota
+	matchFieldFrom
+	matchFieldTo
+	matchFieldCc
+	matchFieldBcc
+	matchFieldLists
+	matchFieldSubject
+	matchFieldBody
+)
+
+// RuleEvaluator represents a filter criteria able to evaluate if an email matches
+// its definition.
+type RuleEvaluator interface {
+	// Match returns true if the given message matches the filter criteria.
+	Match(msg cfg.Message) bool
+}
+
+type andNode struct {
+	children []RuleEvaluator
+}
+
+func (n andNode) Match(msg cfg.Message) bool {
+	for _, c := range n.children {
+		if !c.Match(msg) {
+			return false
+		}
+	}
+	return true
+}
+
+type orNode struct {
+	children []RuleEvaluator
+}
+
+func (n orNode) Match(msg cfg.Message) bool {
+	for _, c := range n.children {
+		if c.Match(msg) {
+			return true
+		}
+	}
+	return false
+}
+
+type notNode struct {
+	child RuleEvaluator
+}
+
+func (n notNode) Match(msg cfg.Message) bool {
+	return !n.child.Match(msg)
+}
+
+type funcNode struct {
+	field     matchField
+	expected  string
+	matchType matchType
+}
+
+func (n funcNode) Match(msg cfg.Message) bool {
+	var fields []string
+
+	switch n.field {
+	case matchFieldFrom:
+		fields = []string{msg.From}
+	case matchFieldTo:
+		fields = msg.To
+	case matchFieldCc:
+		fields = msg.Cc
+	case matchFieldBcc:
+		fields = msg.Bcc
+	case matchFieldLists:
+		fields = msg.Lists
+	case matchFieldSubject:
+		fields = []string{msg.Subject}
+	case matchFieldBody:
+		fields = []string{msg.Body}
+	}
+
+	for _, f := range fields {
+		isMatch := false
+		normF := normalizeField(f)
+
+		switch n.matchType {
+		case matchTypeExact:
+			isMatch = normF == n.expected
+		case matchTypeSuffix:
+			isMatch = strings.HasSuffix(normF, n.expected)
+		case matchTypeContains:
+			isMatch = strings.Contains(normF, n.expected)
+		}
+
+		// If there's no match, continue searching.
+		if isMatch {
+			return true
+		}
+	}
+
+	return false
+}
+
+// normalizeField emulates Gmail normalization: @ and . are the same, and
+// the match is case insensitive.
+func normalizeField(a string) string {
+	return strings.ToLower(strings.ReplaceAll(a, "@", "."))
+}
diff --git a/pkg/cfgtest/eval_test.go b/pkg/cfgtest/eval_test.go
new file mode 100644
index 0000000..e9fcd9c
--- /dev/null
+++ b/pkg/cfgtest/eval_test.go
@@ -0,0 +1,86 @@
+package cfgtest
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	cfg "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/parser"
+)
+
+func TestParseEval(t *testing.T) {
+	expr := or(
+		and(
+			fn(parser.FunctionList, parser.OperationOr,
+				"list1.gm.com", "list2@gm.com"),
+			fn1(parser.FunctionTo, "me@gmail.com"),
+		),
+		fn1(parser.FunctionSubject, "Subject"),
+		fn(parser.FunctionFrom, parser.OperationOr, "@google.com", "b"),
+		fn1(parser.FunctionHas, "Important message"),
+		fn1(parser.FunctionHas, "foo@bar.com"),
+	)
+	eval, err := NewEvaluator(expr)
+	if err != nil {
+		t.Fatalf("NewEvaluator failed: %v", err)
+	}
+
+	tests := []struct {
+		name        string
+		message     cfg.Message
+		expectMatch bool
+	}{
+		{
+			name: "subject",
+			message: cfg.Message{
+				Subject: "contains subject yes",
+			},
+			expectMatch: true,
+		},
+		{
+			name: "list with @",
+			message: cfg.Message{
+				Lists: []string{"list1@gm.com"},
+				To:    []string{"me@gmail.com"},
+			},
+			expectMatch: true,
+		},
+		{
+			name: "from google",
+			message: cfg.Message{
+				From: "someone@google.com",
+			},
+			expectMatch: true,
+		},
+		{
+			name: "has from",
+			message: cfg.Message{
+				From: "foo@bar.com",
+			},
+			expectMatch: true,
+		},
+		{
+			name: "has body",
+			message: cfg.Message{
+				Body: "important message",
+			},
+			expectMatch: true,
+		},
+		{
+			name: "list but not to me",
+			message: cfg.Message{
+				Lists: []string{"list1@gm.com"},
+				To:    []string{"notme@gmail.com"},
+			},
+			expectMatch: false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			match := eval.Match(tc.message)
+			assert.Equal(t, tc.expectMatch, match)
+		})
+	}
+}
diff --git a/pkg/cfgtest/exec_test.go b/pkg/cfgtest/exec_test.go
new file mode 100644
index 0000000..0d5e6f0
--- /dev/null
+++ b/pkg/cfgtest/exec_test.go
@@ -0,0 +1,58 @@
+package cfgtest
+
+import (
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mbrt/gmailctl/pkg/apply"
+	cfg "github.com/mbrt/gmailctl/pkg/config"
+	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+)
+
+func read(t *testing.T, path string) []byte {
+	t.Helper()
+	b, err := ioutil.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return b
+}
+
+func readConfig(t *testing.T, path string) cfgv3.Config {
+	t.Helper()
+	path = filepath.Join("testdata", path)
+	res, err := cfg.ReadFile(path, path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return res
+}
+
+func TestExec(t *testing.T) {
+	tests := []struct {
+		path string
+		pass bool
+	}{
+		{path: "pass.jsonnet", pass: true},
+		{path: "invalid.jsonnet", pass: false},
+		{path: "fail.jsonnet", pass: false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.path, func(t *testing.T) {
+			cfg := readConfig(t, tc.path)
+			pres, err := apply.FromConfig(cfg)
+			assert.Nil(t, err)
+			rules, errs := NewFromParserRules(pres.Rules)
+			assert.Empty(t, errs)
+			err = rules.ExecTests(cfg.Tests)
+			if tc.pass {
+				assert.Nil(t, err)
+			} else {
+				assert.NotNil(t, err)
+			}
+		})
+	}
+}
diff --git a/pkg/cfgtest/lib_test.go b/pkg/cfgtest/lib_test.go
new file mode 100644
index 0000000..760b5b1
--- /dev/null
+++ b/pkg/cfgtest/lib_test.go
@@ -0,0 +1,36 @@
+package cfgtest
+
+import "github.com/mbrt/gmailctl/pkg/parser"
+
+func and(children ...parser.CriteriaAST) *parser.Node {
+	return &parser.Node{
+		Operation: parser.OperationAnd,
+		Children:  children,
+	}
+}
+
+func or(children ...parser.CriteriaAST) *parser.Node {
+	return &parser.Node{
+		Operation: parser.OperationOr,
+		Children:  children,
+	}
+}
+
+func not(child parser.CriteriaAST) *parser.Node {
+	return &parser.Node{
+		Operation: parser.OperationNot,
+		Children:  []parser.CriteriaAST{child},
+	}
+}
+
+func fn(ftype parser.FunctionType, op parser.OperationType, args ...string) *parser.Leaf {
+	return &parser.Leaf{
+		Function: ftype,
+		Grouping: op,
+		Args:     args,
+	}
+}
+
+func fn1(ftype parser.FunctionType, arg string) *parser.Leaf {
+	return fn(ftype, parser.OperationNone, arg)
+}
diff --git a/pkg/cfgtest/testdata/fail.jsonnet b/pkg/cfgtest/testdata/fail.jsonnet
new file mode 100644
index 0000000..df61e84
--- /dev/null
+++ b/pkg/cfgtest/testdata/fail.jsonnet
@@ -0,0 +1,47 @@
+local filters = {
+  lists: {
+    or: [
+      {list: 'list1'},
+      {list: 'list2'},
+      {list: 'list3'},
+    ]
+  },
+};
+
+// The config
+{
+  version: 'v1alpha3',
+  rules: [
+    {
+      filter: {
+        and: [
+          {to: 'myalias@gmail.com'},
+          filters.lists,
+        ]
+      },
+      actions: {
+        markImportant: true,
+      }
+    },
+    {
+      filter: filters.lists,
+      actions: {
+        markImportant: false,
+      }
+    },
+  ],
+  tests: [
+    {
+      name: 'wrong test',
+      messages: [
+        {
+          lists: ['list2'],
+          to: ['myalias@gmail.com'],
+        },
+      ],
+      actions: {
+        archive: true,
+      },
+    },
+  ],
+}
diff --git a/pkg/cfgtest/testdata/invalid.jsonnet b/pkg/cfgtest/testdata/invalid.jsonnet
new file mode 100644
index 0000000..6f2bbb0
--- /dev/null
+++ b/pkg/cfgtest/testdata/invalid.jsonnet
@@ -0,0 +1,46 @@
+local filters = {
+  lists: {
+    or: [
+      {list: 'list1'},
+      {list: 'list2'},
+      {list: 'list3'},
+    ]
+  },
+};
+
+// The config
+{
+  version: 'v1alpha3',
+  rules: [
+    {
+      filter: {
+        and: [
+          {to: 'myalias@gmail.com'},
+          filters.lists,
+        ]
+      },
+      actions: {
+        markImportant: true,
+      }
+    },
+    {
+      filter: filters.lists,
+      actions: {
+        markImportant: false,
+      }
+    },
+  ],
+  // The two filters contradict each other.
+  tests: [
+    {
+      name: 'both filters',
+      messages: [
+        {
+          lists: ['list2'],
+          to: ['myalias@gmail.com'],
+        },
+      ],
+      actions: {},
+    },
+  ],
+}
diff --git a/pkg/cfgtest/testdata/pass.jsonnet b/pkg/cfgtest/testdata/pass.jsonnet
new file mode 100644
index 0000000..e16fdf6
--- /dev/null
+++ b/pkg/cfgtest/testdata/pass.jsonnet
@@ -0,0 +1,118 @@
+local filters = {
+  me: {
+    or: [
+      {to: 'pippo@gmail.com'},
+      {to: 'pippo@hotmail.com'},
+    ]
+  },
+
+  notMe: {
+    not: self.me,
+  },
+
+  lists: {
+    or: [
+      {list: 'list1'},
+      {list: 'list2'},
+      {list: 'list3'},
+    ]
+  },
+
+  spam: {
+    or: [
+      {from: 'spammer1'},
+      {from: 'spammer2'},
+      {subject: 'spam mail'},
+      {to: 'pippo+spammy@gmail.com'},
+      {has: 'buy this thing'},
+      {has: 'very important!!!'},
+    ]
+  },
+};
+
+// The config
+{
+  version: 'v1alpha3',
+  rules: [
+    {
+      filter: {
+        and: [
+          filters.lists,
+          {not: filters.me},
+        ]
+      },
+      actions: {
+        labels: ['maillist', 'onemorelabel'],
+        archive: true,
+      }
+    },
+    {
+      filter: {
+        and: [
+          {to: 'myalias@gmail.com'},
+          filters.lists,
+        ]
+      },
+      actions: {
+        markImportant: true,
+      }
+    },
+    {
+      filter: filters.spam,
+      actions: {
+        delete: true,
+      }
+    },
+  ],
+  tests: [
+    {
+      name: 'spam',
+      messages: [
+        {from: 'spammer1'},
+        {subject: 'spam mail'},
+        {to: ['pippo+spammy@gmail.com', 'someone@else.com]']},
+      ],
+      actions: {
+        delete: true,
+      }
+    },
+    {
+      name: 'undirected lists',
+      messages: [
+        {
+          lists: ['list1'],
+          to: ['someone@else.com'],
+        },
+        {lists: ['list2']},
+      ],
+      actions: {
+        labels: ['maillist', 'onemorelabel'],
+        archive: true,
+      }
+    },
+    {
+      name: 'directed lists',
+      messages: [
+        {
+          lists: ['list3'],
+          to: ['pippo@gmail.com'],
+        },
+      ],
+      actions: {},
+    },
+    {
+      name: 'important and mail lists',
+      messages: [
+        {
+          lists: ['list3'],
+          to: ['myalias@gmail.com'],
+        },
+      ],
+      actions: {
+        archive: true,
+        markImportant: true,
+        labels: ['maillist', 'onemorelabel'],
+      },
+    },
+  ],
+}