From d1fe53cc85f0bad2c6ff1846ca8035ac807bba1f Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 20 May 2026 02:03:21 -0700 Subject: [PATCH] feat(cli): add log verbosity flags (#1311) ## What? Adds two global CLI flags for log verbosity: - `--verbose` / `-V` raises the log level to verbose, which emits a `matcha: loaded config with N account(s)` line once config loads. Reserved for additional sites later. - `--debug` raises the log level to debug, which enables the two existing `debug:` log lines in the email-body cache path so they only fire when explicitly requested. A small `internal/loglevel` package holds the level state and the `Debugf` / `Verbosef` / `Infof` helpers. Level state is `sync/atomic`-backed so concurrent goroutines stay race-clean. The two pre-existing `debug:` log sites in `main.go` are routed through `loglevel.Debugf`. The existing `-v` / `--version` short-circuit is preserved unchanged. `--verbose` uses `-V` (capital) as its short form to avoid colliding with `--version`. Flags are parsed before subcommand dispatch by a small `parseGlobalFlags` helper that strips them from `os.Args` so subcommand parsers don't see them. Documented in README under a new "Logging" section. Unit tests cover the level-gating contract: Debugf/Verbosef/Infof each fire only at their level or higher. ## Why? Closes #576. Today there is no way to control matcha's log verbosity from the CLI. The repo has 106 `log.Printf` call sites, two of which already include a `debug:` prefix that runs unconditionally. The issue asked for `--verbose` / `--debug` flags to gate that output. This PR is the smallest change that wires the flags through a level helper without re-classifying every existing log line - that broader cleanup can happen incrementally. Default behavior is unchanged: 104 `log.Printf` calls still fire at the default level. Only the two existing `debug:`-prefixed lines and the new `Verbosef` config-load line are gated. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- README.md | 12 ++++ internal/loglevel/level.go | 47 ++++++++++++++++ internal/loglevel/level_test.go | 97 +++++++++++++++++++++++++++++++++ main.go | 36 +++++++++++- 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 internal/loglevel/level.go create mode 100644 internal/loglevel/level_test.go diff --git a/README.md b/README.md index 3965b80f5f4236b8256c994580303318eba84b2f..976218d64d7fc4c91d6753211a045aa6bbf9364c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,18 @@ matcha send --to alice@example.com --subject "Hello" --body "Sent by my AI agent [Learn more](https://docs.matcha.email/Features/AI_AGENTS) +### Logging + +Matcha supports global logging verbosity flags before the main command or subcommand: + +```bash +matcha --verbose # enable verbose logging +matcha -V daemon status # short form for verbose logging +matcha --debug daemon status # enable debug logging +``` + +The existing `-v` and `--version` flags continue to print the Matcha version. + **AI Rewrite Plugin:** Matcha includes an AI rewrite plugin that allows you to rewrite your email drafts using OpenAI, Ollama, Gemini, or Claude. [Setup Guide](https://docs.matcha.email/setup-guides/ai-rewrite) diff --git a/internal/loglevel/level.go b/internal/loglevel/level.go new file mode 100644 index 0000000000000000000000000000000000000000..d3279c73f6fdd2b54aa65eb378e0da81d59195fa --- /dev/null +++ b/internal/loglevel/level.go @@ -0,0 +1,47 @@ +package loglevel + +import ( + "log" + "sync/atomic" +) + +type Level int32 + +const ( + LevelSilent Level = iota + LevelInfo + LevelVerbose + LevelDebug +) + +var current atomic.Int32 + +func init() { + current.Store(int32(LevelInfo)) +} + +func Set(level Level) { + current.Store(int32(level)) +} + +func Get() Level { + return Level(current.Load()) +} + +func Debugf(format string, args ...any) { + if Get() >= LevelDebug { + log.Printf("debug: "+format, args...) + } +} + +func Verbosef(format string, args ...any) { + if Get() >= LevelVerbose { + log.Printf(format, args...) + } +} + +func Infof(format string, args ...any) { + if Get() >= LevelInfo { + log.Printf(format, args...) + } +} diff --git a/internal/loglevel/level_test.go b/internal/loglevel/level_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2db27bf1cfa788220e306996e7e9ee8bb9655603 --- /dev/null +++ b/internal/loglevel/level_test.go @@ -0,0 +1,97 @@ +package loglevel + +import ( + "bytes" + "log" + "strings" + "testing" +) + +func captureLog(t *testing.T, fn func()) string { + t.Helper() + + var buf bytes.Buffer + originalOutput := log.Writer() + originalFlags := log.Flags() + log.SetOutput(&buf) + log.SetFlags(0) + defer func() { + log.SetOutput(originalOutput) + log.SetFlags(originalFlags) + Set(LevelInfo) + }() + + fn() + + return buf.String() +} + +func TestDebugfRequiresDebugLevel(t *testing.T) { + for _, level := range []Level{LevelSilent, LevelInfo, LevelVerbose} { + Set(level) + output := captureLog(t, func() { + Debugf("details %d", 1) + }) + if output != "" { + t.Fatalf("Debugf wrote at level %v: %q", level, output) + } + } + + Set(LevelDebug) + output := captureLog(t, func() { + Debugf("details %d", 1) + }) + if !strings.Contains(output, "debug: details 1") { + t.Fatalf("Debugf did not write expected message: %q", output) + } +} + +func TestVerbosefRequiresVerboseLevel(t *testing.T) { + Set(LevelInfo) + output := captureLog(t, func() { + Verbosef("more details") + }) + if output != "" { + t.Fatalf("Verbosef wrote at info level: %q", output) + } + + for _, level := range []Level{LevelVerbose, LevelDebug} { + Set(level) + output := captureLog(t, func() { + Verbosef("more details") + }) + if !strings.Contains(output, "more details") { + t.Fatalf("Verbosef did not write at level %v: %q", level, output) + } + } +} + +func TestInfofRequiresInfoLevel(t *testing.T) { + Set(LevelSilent) + output := captureLog(t, func() { + Infof("hello") + }) + if output != "" { + t.Fatalf("Infof wrote at silent level: %q", output) + } + + Set(LevelInfo) + output = captureLog(t, func() { + Infof("hello") + }) + if !strings.Contains(output, "hello") { + t.Fatalf("Infof did not write at info level: %q", output) + } +} + +func TestSetAndGet(t *testing.T) { + Set(LevelDebug) + if Get() != LevelDebug { + t.Fatalf("Get() = %v, want %v", Get(), LevelDebug) + } + + Set(LevelInfo) + if Get() != LevelInfo { + t.Fatalf("Get() = %v, want %v", Get(), LevelInfo) + } +} diff --git a/main.go b/main.go index 7c316bc96863b21d7cf7063d1d6e799e790dcadf..9462de21f2b1f5ec2c90c539e2089293b77135b2 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ import ( "github.com/floatpane/matcha/i18n" _ "github.com/floatpane/matcha/i18n/languages" "github.com/floatpane/matcha/internal/httpclient" + "github.com/floatpane/matcha/internal/loglevel" "github.com/floatpane/matcha/notify" "github.com/floatpane/matcha/plugin" "github.com/floatpane/matcha/sender" @@ -837,7 +838,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Attachments: cachedAttachments, }, m.config.GetBodyCacheThreshold()) if err != nil { - log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err) + loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err) } }() } @@ -1406,7 +1407,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }, m.config.GetBodyCacheThreshold()) if err != nil { - log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err) + loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err) } email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) @@ -3912,7 +3913,37 @@ func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email { return unique } +func parseGlobalFlags(args []string) ([]string, loglevel.Level) { + level := loglevel.LevelInfo + if len(args) <= 1 { + return args, level + } + + filtered := make([]string, 0, len(args)) + filtered = append(filtered, args[0]) + + for i := 1; i < len(args); i++ { + switch args[i] { + case "--debug": + level = loglevel.LevelDebug + case "--verbose", "-V": + if level < loglevel.LevelVerbose { + level = loglevel.LevelVerbose + } + default: + filtered = append(filtered, args[i:]...) + return filtered, level + } + } + + return filtered, level +} + func main() { + args, level := parseGlobalFlags(os.Args) + os.Args = args + loglevel.Set(level) + // If invoked with version flag, print version and exit if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") { fmt.Printf("matcha version %s", version) @@ -4037,6 +4068,7 @@ func main() { } else { cfg, err := config.LoadConfig() if err == nil { + loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs())) if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil { log.Printf("warning: contacts migration failed: %v", migrateErr) }