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) }