Repository: favonia/cloudflare-ddns (go)

Issue:
Implement the emoji-free alternative mode
This is for accessibility. The screen reader does not seem to work particularly well with those emojis. This is relatively easy to implement, thanks to the modular logger.

Pull request:
feat(pp): add an option to disable emojis
Close #262 

Patch:
diff --git a/README.markdown b/README.markdown
index 52b87aa..3a7ffa5 100644
--- a/README.markdown
+++ b/README.markdown
@@ -431,14 +431,15 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
 </details>
 
 <details>
-<summary>👁️ Monitoring the updater</summary>
+<summary>👁️ Logging and Healthchecks</summary>
 
 | Name           | Valid Values                                                                                                                                                      | Meaning                                                                         | Required? | Default Value |
 | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------- | ------------- |
-| `QUIET`        | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                               | Whether the updater should reduce the logging to the standard output            | No        | `false`       |
+| `QUIET`        | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                               | Whether the updater should reduce the logging                                   | No        | `false`       |
+| `EMOJI`        | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                               | Whether the updater should use emojis in the logging                            | No        | `true`        |
 | `HEALTHCHECKS` | [Healthchecks ping URLs](https://healthchecks.io/docs/), such as `https://hc-ping.com/<uuid>` or `https://hc-ping.com/<project-ping-key>/<name-slug>` (see below) | If set, the updater will ping the URL when it successfully updates IP addresses | No        | (unset)       |
 
-For `HEALTHCHECKS`, the updater can work with any server following the [same notification protocol](https://healthchecks.io/docs/http_api/), including but not limited to self-hosted instances of [Healthchecks](https://github.com/healthchecks/healthchecks). Both UUID and Slug URLs are supported, and the updater is compatible with POST-only mode.
+For `HEALTHCHECKS`, the updater can work with any server following the [same notification protocol](https://healthchecks.io/docs/http_api/), including but not limited to self-hosted instances of [Healthchecks](https://github.com/healthchecks/healthchecks). Both UUID and Slug URLs are supported, and the updater is compatible with the POST-only mode.
 
 </details>
 
diff --git a/cmd/ddns.go b/cmd/ddns.go
index 6bd77ef..6fc4d99 100644
--- a/cmd/ddns.go
+++ b/cmd/ddns.go
@@ -89,7 +89,7 @@ func stopUpdating(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.S
 
 func main() { //nolint:funlen
 	ppfmt := pp.New(os.Stdout)
-	if !config.ReadQuiet("QUIET", &ppfmt) {
+	if !config.ReadEmoji("EMOJI", &ppfmt) || !config.ReadQuiet("QUIET", &ppfmt) {
 		ppfmt.Noticef(pp.EmojiUserError, "Bye!")
 		return
 	}
diff --git a/internal/config/env.go b/internal/config/env.go
index f62844d..1c067ab 100644
--- a/internal/config/env.go
+++ b/internal/config/env.go
@@ -31,6 +31,24 @@ func ReadString(ppfmt pp.PP, key string, field *string) bool {
 	return true
 }
 
+// ReadEmoji reads an environment variable as emoji/no-emoji.
+func ReadEmoji(key string, ppfmt *pp.PP) bool {
+	val := Getenv(key)
+	if val == "" {
+		return true
+	}
+
+	emoji, err := strconv.ParseBool(val)
+	if err != nil {
+		(*ppfmt).Errorf(pp.EmojiUserError, "Failed to parse %q: %v", val, err)
+		return false
+	}
+
+	*ppfmt = (*ppfmt).SetEmoji(emoji)
+
+	return true
+}
+
 // ReadQuiet reads an environment variable as quiet/verbose.
 func ReadQuiet(key string, ppfmt *pp.PP) bool {
 	val := Getenv(key)
diff --git a/internal/mocks/mock_pp.go b/internal/mocks/mock_pp.go
index 81e2845..ea5b47c 100644
--- a/internal/mocks/mock_pp.go
+++ b/internal/mocks/mock_pp.go
@@ -113,6 +113,20 @@ func (mr *MockPPMockRecorder) Noticef(arg0, arg1 interface{}, arg2 ...interface{
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Noticef", reflect.TypeOf((*MockPP)(nil).Noticef), varargs...)
 }
 
+// SetEmoji mocks base method.
+func (m *MockPP) SetEmoji(arg0 bool) pp.PP {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SetEmoji", arg0)
+	ret0, _ := ret[0].(pp.PP)
+	return ret0
+}
+
+// SetEmoji indicates an expected call of SetEmoji.
+func (mr *MockPPMockRecorder) SetEmoji(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmoji", reflect.TypeOf((*MockPP)(nil).SetEmoji), arg0)
+}
+
 // SetLevel mocks base method.
 func (m *MockPP) SetLevel(arg0 pp.Level) pp.PP {
 	m.ctrl.T.Helper()
diff --git a/internal/pp/base.go b/internal/pp/base.go
index 0525087..60f1e22 100644
--- a/internal/pp/base.go
+++ b/internal/pp/base.go
@@ -3,6 +3,7 @@ package pp
 //go:generate mockgen -destination=../mocks/mock_pp.go -package=mocks . PP
 
 type PP interface {
+	SetEmoji(bool) PP
 	SetLevel(Level) PP
 	IsEnabledFor(Level) bool
 	IncIndent() PP
diff --git a/internal/pp/fmt.go b/internal/pp/fmt.go
index 508da8c..035f847 100644
--- a/internal/pp/fmt.go
+++ b/internal/pp/fmt.go
@@ -8,6 +8,7 @@ import (
 
 type formatter struct {
 	writer io.Writer
+	emoji  bool
 	indent int
 	level  Level
 }
@@ -15,17 +16,22 @@ type formatter struct {
 func New(writer io.Writer) PP {
 	return &formatter{
 		writer: writer,
+		emoji:  true,
 		indent: 0,
 		level:  DefaultLevel,
 	}
 }
 
+func (f *formatter) SetEmoji(emoji bool) PP {
+	fmt := *f
+	fmt.emoji = emoji
+	return &fmt
+}
+
 func (f *formatter) SetLevel(lvl Level) PP {
-	return &formatter{
-		writer: f.writer,
-		indent: f.indent,
-		level:  lvl,
-	}
+	fmt := *f
+	fmt.level = lvl
+	return &fmt
 }
 
 func (f *formatter) IsEnabledFor(lvl Level) bool {
@@ -33,11 +39,9 @@ func (f *formatter) IsEnabledFor(lvl Level) bool {
 }
 
 func (f *formatter) IncIndent() PP {
-	return &formatter{
-		writer: f.writer,
-		indent: f.indent + 1,
-		level:  f.level,
-	}
+	fmt := *f
+	fmt.indent++
+	return &fmt
 }
 
 func (f *formatter) output(lvl Level, emoji Emoji, msg string) {
@@ -45,10 +49,17 @@ func (f *formatter) output(lvl Level, emoji Emoji, msg string) {
 		return
 	}
 
-	line := fmt.Sprintf("%s%s %s",
-		strings.Repeat(indentPrefix, f.indent),
-		string(emoji),
-		msg)
+	var line string
+	if f.emoji {
+		line = fmt.Sprintf("%s%s %s",
+			strings.Repeat(indentPrefix, f.indent),
+			string(emoji),
+			msg)
+	} else {
+		line = fmt.Sprintf("%s%s",
+			strings.Repeat(indentPrefix, f.indent),
+			msg)
+	}
 	line = strings.TrimSuffix(line, "\n")
 	fmt.Fprintln(f.writer, line)
 }


Test patch:
diff --git a/internal/config/env_test.go b/internal/config/env_test.go
index cd1ed40..cde1363 100644
--- a/internal/config/env_test.go
+++ b/internal/config/env_test.go
@@ -109,6 +109,53 @@ func TestReadString(t *testing.T) {
 	}
 }
 
+//nolint:paralleltest // environment vars are global
+func TestReadEmoji(t *testing.T) {
+	key := keyPrefix + "EMOJI"
+	for name, tc := range map[string]struct {
+		set           bool
+		val           string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"nil":   {false, "", true, nil},
+		"empty": {true, " ", true, nil},
+		"true": {
+			true, " true", true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().SetEmoji(true)
+			},
+		},
+		"false": {
+			true, "    false ", true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().SetEmoji(false)
+			},
+		},
+		"illform": {
+			true, "weird", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "weird", gomock.Any())
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			set(t, key, tc.set, tc.val)
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
+			var wrappedPP pp.PP = mockPP
+
+			ok := config.ReadEmoji(key, &wrappedPP)
+			require.Equal(t, tc.ok, ok)
+		})
+	}
+}
+
 //nolint:paralleltest // environment vars are global
 func TestReadQuiet(t *testing.T) {
 	key := keyPrefix + "QUIET"
diff --git a/internal/pp/fmt_test.go b/internal/pp/fmt_test.go
index 41b87a4..9df5571 100644
--- a/internal/pp/fmt_test.go
+++ b/internal/pp/fmt_test.go
@@ -60,20 +60,25 @@ func TestPrint(t *testing.T) {
 	t.Parallel()
 
 	for name, tc := range map[string]struct {
+		emoji    bool
 		level    pp.Level
 		expected string
 	}{
-		"info":     {pp.Info, "🌟 info\n🌟 notice\n🌟 warning\n🌟 error\n"},
-		"notice":   {pp.Notice, "🌟 notice\n🌟 warning\n🌟 error\n"},
-		"warning":  {pp.Warning, "🌟 warning\n🌟 error\n"},
-		"errorfmt": {pp.Error, "🌟 error\n"},
+		"info":              {true, pp.Info, "🌟 info\n🌟 notice\n🌟 warning\n🌟 error\n"},
+		"notice":            {true, pp.Notice, "🌟 notice\n🌟 warning\n🌟 error\n"},
+		"warning":           {true, pp.Warning, "🌟 warning\n🌟 error\n"},
+		"errorfmt":          {true, pp.Error, "🌟 error\n"},
+		"info/no-emoji":     {false, pp.Info, "info\nnotice\nwarning\nerror\n"},
+		"notice/no-emoji":   {false, pp.Notice, "notice\nwarning\nerror\n"},
+		"warning/no-emoji":  {false, pp.Warning, "warning\nerror\n"},
+		"errorfmt/no-emoji": {false, pp.Error, "error\n"},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
 
 			var buf strings.Builder
-			fmt := pp.New(&buf).SetLevel(tc.level)
+			fmt := pp.New(&buf).SetEmoji(tc.emoji).SetLevel(tc.level)
 
 			fmt.Infof(pp.EmojiStar, "info")
 			fmt.Noticef(pp.EmojiStar, "notice")