Repository: mbrt/gmailctl (go)

Issue:
Feature request: Display URL to test the new rules
**My use case:** I get an email or two that I would like to create a filter for. I update my configuration and run `gmailctl diff` / `gmailctl apply`, getting an output like this:

```
$ gmailctl diff
Filters:
--- Current
+++ TO BE APPLIED
@@ -1 +1,6 @@
+* Criteria:
+    from: noreply+spam@acme.com
+  Actions:
+    archive
+    apply label: n
```

Then I want to go and confirm that the new filter will match the emails. I love to test things, especially when the rule is complicated or includes special things like `+` in the email.

Testing involves copying the criteria into gmail search bar. It's bothersome if the rule is complicated (ANDs / ORs) and I need to partially rewrite the query, beating the purpose of testing in the first place.

**Feature request:** I would appreciate if the diff for each rule that is modified included gmail search link / direct search query for gmail. Such output is present already in the `gmailctl debug` output.

(I don't like using `gmailctl debug` for this purpose as it prints all rules out of which I need to find the new one).

The output could like:

```
--- Current
+++ TO BE APPLIED
@@ -1 +1,6 @@
+# Search: from:noreply+spam@acme.com
+# URL: https://mail.google.com/mail/u/0/#search/from%3Anoreply%2Bspam%40acme.com
+* Criteria:
+    from: noreply+spam@acme.com
+  Actions:
+    archive
+    apply label: n
```

When the filter would be modified, the output could look like this:

```
$ gmailctl diff
Filters:
--- Current
+++ TO BE APPLIED
@@ -1,6 +1,6 @@
 # Search: from:noreply+spam@acme.com
 # URL: https://mail.google.com/mail/u/0/#search/from%3Anoreply%2Bspam%40acme.com
 * Criteria:
     from: noreply+borgcron@google.com
   Actions:
     archive
-    apply label: n
+    apply label: nnn
```

I would be okay if the feature would be enabled only when a flag is present, something like `--debug` perhaps?

Pull request description:
Add `--debug` flag to display Gmail search URL
When creating a complicated search criteria, it can be helpful to verify that it matches the emails. If you specify `--debug` on diff, apply, edit commands, URL to Gmail search will be displayed in the diff.

Fixes #407

Patch:
diff --git a/cmd/gmailctl/cmd/apply_cmd.go b/cmd/gmailctl/cmd/apply_cmd.go
index 3dc0e2f..25017fe 100644
--- a/cmd/gmailctl/cmd/apply_cmd.go
+++ b/cmd/gmailctl/cmd/apply_cmd.go
@@ -14,6 +14,7 @@ var (
 	applyYes          bool
 	applyRemoveLabels bool
 	applySkipTests    bool
+	applyDebug        bool
 )
 
 const renameLabelWarning = `Warning: You are going to delete labels. This operation is
@@ -50,6 +51,7 @@ func init() {
 	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")
+	applyCmd.PersistentFlags().BoolVarP(&applyDebug, "debug", "", false, "print extra debugging information")
 }
 
 func apply(path string, interactive, test bool) error {
@@ -68,7 +70,7 @@ func apply(path string, interactive, test bool) error {
 		return err
 	}
 
-	diff, err := papply.Diff(parseRes.Res.GmailConfig, upstream)
+	diff, err := papply.Diff(parseRes.Res.GmailConfig, upstream, applyDebug)
 	if err != nil {
 		return fmt.Errorf("cannot compare upstream with local config: %w", err)
 	}
diff --git a/cmd/gmailctl/cmd/debug_cmd.go b/cmd/gmailctl/cmd/debug_cmd.go
index b54c9b7..cdfb119 100644
--- a/cmd/gmailctl/cmd/debug_cmd.go
+++ b/cmd/gmailctl/cmd/debug_cmd.go
@@ -2,7 +2,6 @@ package cmd
 
 import (
 	"fmt"
-	"net/url"
 
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v3"
@@ -63,10 +62,9 @@ func debug(path string) error {
 		if err != nil {
 			return fmt.Errorf("generating criteria: %w", err)
 		}
-		search := criteria.ToGmailSearch()
 
-		fmt.Printf("# Search: %s\n", search)
-		fmt.Printf("# URL: %s\n", toGmailURL(search))
+		fmt.Printf("# Search: %s\n", criteria.ToGmailSearch())
+		fmt.Printf("# URL: %s\n", criteria.ToGmailSearchURL())
 		cfg := parsedRules[i]
 		b, err := yaml.Marshal(cfg)
 		if err != nil {
@@ -77,10 +75,3 @@ func debug(path string) error {
 
 	return nil
 }
-
-func toGmailURL(s string) string {
-	return fmt.Sprintf(
-		"https://mail.google.com/mail/u/0/#search/%s",
-		url.QueryEscape(s),
-	)
-}
diff --git a/cmd/gmailctl/cmd/diff_cmd.go b/cmd/gmailctl/cmd/diff_cmd.go
index 3160ad0..b138e8d 100644
--- a/cmd/gmailctl/cmd/diff_cmd.go
+++ b/cmd/gmailctl/cmd/diff_cmd.go
@@ -10,6 +10,7 @@ import (
 
 var (
 	diffFilename string
+	diffDebug    bool
 )
 
 // diffCmd represents the diff command
@@ -37,6 +38,7 @@ func init() {
 
 	// Flags and configuration settings
 	diffCmd.PersistentFlags().StringVarP(&diffFilename, "filename", "f", "", "configuration file")
+	diffCmd.PersistentFlags().BoolVarP(&diffDebug, "debug", "", false, "print extra debugging information")
 }
 
 func diff(path string) error {
@@ -55,7 +57,7 @@ func diff(path string) error {
 		return err
 	}
 
-	diff, err := papply.Diff(parseRes.Res.GmailConfig, upstream)
+	diff, err := papply.Diff(parseRes.Res.GmailConfig, upstream, diffDebug)
 	if err != nil {
 		return fmt.Errorf("cannot compare upstream with local config: %w", err)
 	}
diff --git a/cmd/gmailctl/cmd/edit_cmd.go b/cmd/gmailctl/cmd/edit_cmd.go
index 979acd7..6af5599 100644
--- a/cmd/gmailctl/cmd/edit_cmd.go
+++ b/cmd/gmailctl/cmd/edit_cmd.go
@@ -19,6 +19,7 @@ import (
 var (
 	editFilename  string
 	editSkipTests bool
+	editDebug     bool
 )
 
 var (
@@ -68,6 +69,7 @@ func init() {
 	// Flags and configuration settings
 	editCmd.PersistentFlags().StringVarP(&editFilename, "filename", "f", "", "configuration file")
 	editCmd.Flags().BoolVarP(&editSkipTests, "yolo", "", false, "skip configuration tests")
+	editCmd.PersistentFlags().BoolVarP(&editDebug, "debug", "", false, "print extra debugging information")
 }
 
 func edit(path string, test bool) error {
@@ -221,7 +223,7 @@ func applyEdited(path, originalPath string, test bool, gmailapi *api.GmailAPI) e
 		return err
 	}
 
-	diff, err := papply.Diff(parseRes.Res.GmailConfig, upstream)
+	diff, err := papply.Diff(parseRes.Res.GmailConfig, upstream, editDebug)
 	if err != nil {
 		return errors.New("comparing upstream with local config")
 	}
diff --git a/go.mod b/go.mod
index 28182d8..bb2110b 100644
--- a/go.mod
+++ b/go.mod
@@ -10,14 +10,14 @@ require (
 	github.com/spf13/cobra v1.8.1
 	github.com/stretchr/testify v1.9.0
 	golang.org/x/oauth2 v0.21.0
-	google.golang.org/api v0.188.0
+	google.golang.org/api v0.186.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
-	cloud.google.com/go/auth v0.7.0 // indirect
+	cloud.google.com/go/auth v0.6.0 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
-	cloud.google.com/go/compute/metadata v0.4.0 // indirect
+	cloud.google.com/go/compute/metadata v0.3.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/go-logr/logr v1.4.1 // indirect
@@ -36,12 +36,12 @@ require (
 	go.opentelemetry.io/otel v1.24.0 // indirect
 	go.opentelemetry.io/otel/metric v1.24.0 // indirect
 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
-	golang.org/x/crypto v0.25.0 // indirect
-	golang.org/x/net v0.27.0 // indirect
-	golang.org/x/sys v0.22.0 // indirect
+	golang.org/x/crypto v0.24.0 // indirect
+	golang.org/x/net v0.26.0 // indirect
+	golang.org/x/sys v0.21.0 // indirect
 	golang.org/x/text v0.16.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b // indirect
-	google.golang.org/grpc v1.64.1 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect
+	google.golang.org/grpc v1.64.0 // indirect
 	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	sigs.k8s.io/yaml v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index c02da8a..7eca21a 100644
--- a/go.sum
+++ b/go.sum
@@ -5,14 +5,10 @@ cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg=
 cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc=
 cloud.google.com/go/auth v0.6.0 h1:5x+d6b5zdezZ7gmLWD1m/xNjnaQ2YDhmIz/HH3doy1g=
 cloud.google.com/go/auth v0.6.0/go.mod h1:b4acV+jLQDyjwm4OXHYjNvRi4jvGBzHWJRtJcy+2P4g=
-cloud.google.com/go/auth v0.7.0 h1:kf/x9B3WTbBUHkC+1VS8wwwli9TzhSt0vSTVBmMR8Ts=
-cloud.google.com/go/auth v0.7.0/go.mod h1:D+WqdrpcjmiCgWrXmLLxOVq1GACoE36chW6KXoEvuIw=
 cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
 cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
 cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
 cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
-cloud.google.com/go/compute/metadata v0.4.0 h1:vHzJCWaM4g8XIcm8kopr3XmDA4Gy/lblD3EhhSux05c=
-cloud.google.com/go/compute/metadata v0.4.0/go.mod h1:SIQh1Kkb4ZJ8zJ874fqVkslA29PRXuleyj6vOzlbK7M=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -119,8 +115,6 @@ golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
 golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
-golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
-golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -137,8 +131,6 @@ golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
 golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
 golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
@@ -161,8 +153,6 @@ golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
 golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
@@ -183,8 +173,6 @@ google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE=
 google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM=
 google.golang.org/api v0.186.0 h1:n2OPp+PPXX0Axh4GuSsL5QL8xQCTb2oDwyzPnQvqUug=
 google.golang.org/api v0.186.0/go.mod h1:hvRbBmgoje49RV3xqVXrmP6w93n6ehGgIVPYrGtBFFc=
-google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
-google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -199,8 +187,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b h1:04+jVzTs2XBnOZcPsLnmrTGqltqJbZQ1Ey26hjYdQQ0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
@@ -210,8 +196,6 @@ google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
 google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
 google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
 google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
-google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
-google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
diff --git a/internal/engine/apply/apply.go b/internal/engine/apply/apply.go
index bca336c..288b769 100644
--- a/internal/engine/apply/apply.go
+++ b/internal/engine/apply/apply.go
@@ -113,13 +113,13 @@ func (d ConfigDiff) Validate() error {
 }
 
 // Diff computes the diff between local and upstream configuration.
-func Diff(local, upstream GmailConfig) (ConfigDiff, error) {
+func Diff(local, upstream GmailConfig, debugInfo bool) (ConfigDiff, error) {
 	res := ConfigDiff{
 		LocalConfig: local,
 	}
 	var err error
 
-	res.FiltersDiff, err = filter.Diff(upstream.Filters, local.Filters)
+	res.FiltersDiff, err = filter.Diff(upstream.Filters, local.Filters, debugInfo)
 	if err != nil {
 		return res, fmt.Errorf("cannot compute filters diff: %w", err)
 	}
diff --git a/internal/engine/filter/diff.go b/internal/engine/filter/diff.go
index 5d797c5..51057dc 100644
--- a/internal/engine/filter/diff.go
+++ b/internal/engine/filter/diff.go
@@ -14,12 +14,12 @@ import (
 // Diff computes the diff between two lists of filters.
 //
 // To compute the diff, IDs are ignored, only the contents of the filters are actually considered.
-func Diff(upstream, local Filters) (FiltersDiff, error) {
+func Diff(upstream, local Filters, debugInfo bool) (FiltersDiff, error) {
 	// Computing the diff is very expensive, so we have to minimize the number of filters
 	// we have to analyze. To do so, we get rid of the filters that are exactly the same,
 	// by hashing them.
 	added, removed := changedFilters(upstream, local)
-	return NewMinimalFiltersDiff(added, removed), nil
+	return NewMinimalFiltersDiff(added, removed, debugInfo), nil
 }
 
 // NewMinimalFiltersDiff creates a new FiltersDiff with reordered filters, where
@@ -28,17 +28,18 @@ func Diff(upstream, local Filters) (FiltersDiff, error) {
 // The algorithm used is a quadratic approximation to the otherwise NP-complete
 // travel salesman problem. Hopefully the number of filters is low enough to
 // make this not too slow and the approximation not too bad.
-func NewMinimalFiltersDiff(added, removed Filters) FiltersDiff {
+func NewMinimalFiltersDiff(added, removed Filters, printDebugInfo bool) FiltersDiff {
 	if len(added) > 0 && len(removed) > 0 {
 		added, removed = reorderWithHungarian(added, removed)
 	}
-	return FiltersDiff{added, removed}
+	return FiltersDiff{added, removed, printDebugInfo}
 }
 
 // FiltersDiff contains filters that have been added and removed locally with respect to upstream.
 type FiltersDiff struct {
-	Added   Filters
-	Removed Filters
+	Added          Filters
+	Removed        Filters
+	PrintDebugInfo bool
 }
 
 // Empty returns true if the diff is empty.
@@ -47,16 +48,25 @@ func (f FiltersDiff) Empty() bool {
 }
 
 func (f FiltersDiff) String() string {
+	var removed, added string
+	if f.PrintDebugInfo {
+		removed = f.Removed.DebugString()
+		added = f.Added.DebugString()
+	} else {
+		removed = f.Removed.String()
+		added = f.Added.String()
+	}
+
 	s, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
-		A:        difflib.SplitLines(f.Removed.String()),
-		B:        difflib.SplitLines(f.Added.String()),
+		A:        difflib.SplitLines(removed),
+		B:        difflib.SplitLines(added),
 		FromFile: "Current",
 		ToFile:   "TO BE APPLIED",
 		Context:  5,
 	})
 	if err != nil {
 		// We can't get a diff apparently, let's make something up here
-		return fmt.Sprintf("Removed:\n%s\nAdded:\n%s", f.Removed, f.Added)
+		return fmt.Sprintf("Removed:\n%s\nAdded:\n%s", removed, added)
 	}
 	return s
 }
diff --git a/internal/engine/filter/filter.go b/internal/engine/filter/filter.go
index 3ee757c..7c112d3 100644
--- a/internal/engine/filter/filter.go
+++ b/internal/engine/filter/filter.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"io"
+	"net/url"
 	"strings"
 
 	"github.com/mbrt/gmailctl/internal/engine/gmail"
@@ -27,6 +28,21 @@ func (fs Filters) String() string {
 	return w.String()
 }
 
+func (fs Filters) DebugString() string {
+	w := writer{}
+
+	first := true
+	for _, f := range fs {
+		if !first {
+			w.WriteRune('\n')
+		}
+		first = false
+		w.WriteString(f.DebugString())
+	}
+
+	return w.String()
+}
+
 // HasLabel returns true if the given label is used by at least one filter.
 func (fs Filters) HasLabel(name string) bool {
 	for _, f := range fs {
@@ -70,6 +86,18 @@ func (f Filter) String() string {
 	return w.String()
 }
 
+// DebugString returns text representation of the filter with extra debugging
+// information Gmail search representation and URL included.
+func (f Filter) DebugString() string {
+	w := writer{}
+
+	w.WriteString(fmt.Sprintf("# Search: %s\n", f.Criteria.ToGmailSearch()))
+	w.WriteString(fmt.Sprintf("# URL: %s\n", f.Criteria.ToGmailSearchURL()))
+	w.WriteString(f.String())
+
+	return w.String()
+}
+
 func indent(query string, level int) string {
 	var indented bytes.Buffer
 	if !indentInternal(strings.NewReader(query), &indented, level+1) {
@@ -214,6 +242,13 @@ func (c Criteria) ToGmailSearch() string {
 	return strings.Join(res, " ")
 }
 
+// ToGmailSearchURL returns the equivalent query in an URL to Gmail search.
+func (c Criteria) ToGmailSearchURL() string {
+	return fmt.Sprintf(
+		"https://mail.google.com/mail/u/0/#search/%s",
+		url.QueryEscape(c.ToGmailSearch()))
+}
+
 type writer struct {
 	b   strings.Builder
 	err error
diff --git a/internal/graph/munkres.go b/internal/graph/munkres.go
index 32cc744..460f4a4 100644
--- a/internal/graph/munkres.go
+++ b/internal/graph/munkres.go
@@ -24,18 +24,18 @@ const (
 )
 
 // Munkres (Hungarian algorithm) method to solve the assignment problem
-//  based on code by Bob Pilgrim from http://csclab.murraystate.edu/bob.pilgrim/445/munkres.html
-//  Note: this method runs in O(n³), in the worst case; therefore is not efficient for large matrix
-//   Example:
-//           $ | Clean  Sweep   Wash
-//      -------|--------------------
-//      Fry    |   [2]      3      3
-//      Leela  |     3    [2]      3
-//      Bender |     3      3    [2]
-//      minimum cost = 6
 //
-//  Note: cost will be minimised
+//	based on code by Bob Pilgrim from http://csclab.murraystate.edu/bob.pilgrim/445/munkres.html
+//	Note: this method runs in O(n³), in the worst case; therefore is not efficient for large matrix
+//	 Example:
+//	         $ | Clean  Sweep   Wash
+//	    -------|--------------------
+//	    Fry    |   [2]      3      3
+//	    Leela  |     3    [2]      3
+//	    Bender |     3      3    [2]
+//	    minimum cost = 6
 //
+//	Note: cost will be minimised
 type Munkres struct {
 
 	// main
@@ -80,7 +80,8 @@ func (o *Munkres) Init(nrow, ncol int) {
 }
 
 // SetCostMatrix sets cost matrix by copying from C to internal o.C
-//  Note: costs must be positive
+//
+//	Note: costs must be positive
 func (o *Munkres) SetCostMatrix(C [][]float64) {
 	o.Cori = C
 	for i := 0; i < o.nrowOri; i++ {
@@ -101,11 +102,12 @@ func (o *Munkres) SetCostMatrix(C [][]float64) {
 }
 
 // Run runs the iterative algorithm
-//  Output:
-//   o.Links -- will contain assignments, where len(assignments) == nrow and
-//              j := o.Links[i] means that i is assigned to j
-//              -1 means no assignment/link
-//   o.Cost -- will have the total cost by following links
+//
+//	Output:
+//	 o.Links -- will contain assignments, where len(assignments) == nrow and
+//	            j := o.Links[i] means that i is assigned to j
+//	            -1 means no assignment/link
+//	 o.Cost -- will have the total cost by following links
 func (o *Munkres) Run() {
 
 	// column matrix


Test patch:
diff --git a/integration_test.go b/integration_test.go
index ddb4de6..7d5cfa1 100644
--- a/integration_test.go
+++ b/integration_test.go
@@ -68,7 +68,7 @@ func TestIntegration(t *testing.T) {
 			require.Nil(t, err)
 
 			// Apply the diff.
-			d, err := apply.Diff(pres.GmailConfig, upres)
+			d, err := apply.Diff(pres.GmailConfig, upres, false)
 			require.Nil(t, err)
 			err = apply.Apply(d, gapi, true)
 			require.Nil(t, err)
@@ -139,7 +139,7 @@ func TestIntegrationImportExport(t *testing.T) {
 			require.Nil(t, err)
 
 			// Apply the diff.
-			d, err := apply.Diff(pres.GmailConfig, upres)
+			d, err := apply.Diff(pres.GmailConfig, upres, false)
 			require.Nil(t, err)
 			err = apply.Apply(d, gapi, true)
 			require.Nil(t, err)
@@ -174,7 +174,7 @@ func TestIntegrationImportExport(t *testing.T) {
 }
 
 func assertEmptyDiff(t *testing.T, local, remote apply.GmailConfig) {
-	d, err := apply.Diff(local, remote)
+	d, err := apply.Diff(local, remote, false)
 	require.Nil(t, err)
 	assert.Empty(t, d.FiltersDiff)
 	assert.Empty(t, d.LabelsDiff)
diff --git a/internal/engine/filter/diff_test.go b/internal/engine/filter/diff_test.go
index 68611d8..10f7288 100644
--- a/internal/engine/filter/diff_test.go
+++ b/internal/engine/filter/diff_test.go
@@ -32,7 +32,7 @@ func TestNoDiff(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	assert.Nil(t, err)
 	// No difference even if the ID is present in only one of them.
 	assert.True(t, fd.Empty())
@@ -65,7 +65,7 @@ func TestDiffOutput(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	assert.Nil(t, err)
 
 	expected := `
@@ -92,6 +92,64 @@ func TestDiffOutput(t *testing.T) {
 	assert.Equal(t, strings.TrimSpace(fd.String()), strings.TrimSpace(expected))
 }
 
+func TestDiffOutputWithGmailSearchURL(t *testing.T) {
+	old := Filters{
+		{
+			ID: "abcdefg",
+			Criteria: Criteria{
+				From:  "someone@gmail.com",
+				Query: "(a b) subject:(foo bar)",
+			},
+			Action: Actions{
+				MarkRead: true,
+				Category: gmail.CategoryPersonal,
+			},
+		},
+	}
+	new := Filters{
+		{
+			Criteria: Criteria{
+				From:  "{someone@gmail.com else@gmail.com}",
+				Query: "(a c) subject:(foo baz)",
+			},
+			Action: Actions{
+				MarkRead: true,
+				Category: gmail.CategoryPersonal,
+			},
+		},
+	}
+
+	fd, err := Diff(old, new, true)
+	assert.Nil(t, err)
+
+	expected := `
+--- Current
++++ TO BE APPLIED
+@@ -1,17 +1,17 @@
+-# Search: from:someone@gmail.com (a b) subject:(foo bar)
+-# URL: https://mail.google.com/mail/u/0/#search/from%3Asomeone%40gmail.com+%28a+b%29+subject%3A%28foo+bar%29
++# Search: from:{someone@gmail.com else@gmail.com} (a c) subject:(foo baz)
++# URL: https://mail.google.com/mail/u/0/#search/from%3A%7Bsomeone%40gmail.com+else%40gmail.com%7D+%28a+c%29+subject%3A%28foo+baz%29
+ * Criteria:
+-    from: someone@gmail.com
++    from: {someone@gmail.com else@gmail.com}
+     query: 
+       (
+         a
+-        b
++        c
+       )
+       subject:(
+         foo
+-        bar
++        baz
+       )
+   Actions:
+     mark as read
+     categorize as: personal`
+	assert.Equal(t, strings.TrimSpace(fd.String()), strings.TrimSpace(expected))
+}
+
 func someFilters() Filters {
 	return Filters{
 		{
@@ -155,7 +213,7 @@ func TestDiffAddRemove(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	expected := FiltersDiff{
 		Added:   Filters{new[0]},
 		Removed: Filters{old[1]},
@@ -194,7 +252,7 @@ func TestDiffReorder(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	assert.Nil(t, err)
 	assert.Len(t, fd.Added, 0)
 	assert.Len(t, fd.Removed, 0)
@@ -230,7 +288,7 @@ func TestDiffModify(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	expected := FiltersDiff{
 		Added:   Filters{new[1]},
 		Removed: Filters{old[1]},
@@ -278,7 +336,7 @@ func TestDiffAdd(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	expected := FiltersDiff{
 		Added: Filters{new[2]},
 	}
@@ -300,7 +358,7 @@ func TestDiffRemove(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	expected := FiltersDiff{
 		Removed: Filters{old[2], old[0]},
 	}
@@ -330,7 +388,7 @@ func TestDuplicate(t *testing.T) {
 		},
 	}
 
-	fd, err := Diff(old, new)
+	fd, err := Diff(old, new, false)
 	assert.Nil(t, err)
 	// Only one of the two identical filters is present
 	assert.Equal(t, new[1:], fd.Added)
diff --git a/internal/engine/filter/filter_test.go b/internal/engine/filter/filter_test.go
new file mode 100644
index 0000000..c3b3856
--- /dev/null
+++ b/internal/engine/filter/filter_test.go
@@ -0,0 +1,49 @@
+package filter
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCriteria(t *testing.T) {
+	tests := []struct {
+		name           string
+		criteria       Criteria
+		gmailSearch    string
+		gmailSearchURL string
+	}{
+		{
+			name:           "from criteria",
+			criteria:       Criteria{From: "someone@gmail.com"},
+			gmailSearch:    "from:someone@gmail.com",
+			gmailSearchURL: "https://mail.google.com/mail/u/0/#search/from%3Asomeone%40gmail.com",
+		},
+		{
+			name: "complicated query",
+			criteria: Criteria{
+				Query: "{from:noreply@acme.com to:{me@google.com me@acme.com}}",
+			},
+			gmailSearch:    "{from:noreply@acme.com to:{me@google.com me@acme.com}}",
+			gmailSearchURL: "https://mail.google.com/mail/u/0/#search/%7Bfrom%3Anoreply%40acme.com+to%3A%7Bme%40google.com+me%40acme.com%7D%7D",
+		},
+		{
+			name: "all fileds",
+			criteria: Criteria{
+				From:    "someone@gmail.com",
+				To:      "me@gmail.com",
+				Subject: "Hello world",
+				Query:   "unsubscribe",
+			},
+			gmailSearch:    "from:someone@gmail.com to:me@gmail.com subject:Hello world unsubscribe",
+			gmailSearchURL: "https://mail.google.com/mail/u/0/#search/from%3Asomeone%40gmail.com+to%3Ame%40gmail.com+subject%3AHello+world+unsubscribe",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.criteria.ToGmailSearch(), tc.gmailSearch)
+			assert.Equal(t, tc.criteria.ToGmailSearchURL(), tc.gmailSearchURL)
+		})
+	}
+}