diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 0b83c48..1d8589e 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -29,6 +29,7 @@ import ( "github.com/emersion/go-message/mail" "github.com/emersion/go-pgpmail" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/internal/loglevel" "go.mozilla.org/pkcs7" "golang.org/x/text/encoding" "golang.org/x/text/encoding/ianaindex" @@ -43,6 +44,18 @@ var ( debugIMAPOnce sync.Once ) +type debugKittyLogFile interface { + WriteString(string) (int, error) + Close() error +} + +var ( + debugKittyOpenLogFile = func(path string) (debugKittyLogFile, error) { + return os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + } + debugKittyLogErrorf = loglevel.Infof +) + func getDebugIMAPWriter() io.Writer { debugIMAPOnce.Do(func() { if path := os.Getenv("DEBUG_IMAP"); path != "" { @@ -58,6 +71,22 @@ func getDebugIMAPWriter() io.Writer { return nil } +func writeDebugKittyLog(path, msg string) { + f, err := debugKittyOpenLogFile(path) + if err != nil { + debugKittyLogErrorf("failed to open debug kitty log %s: %v", path, err) + return + } + defer func() { + if err := f.Close(); err != nil { + debugKittyLogErrorf("failed to close debug kitty log %s: %v", path, err) + } + }() + if _, err := f.WriteString(msg); err != nil { + debugKittyLogErrorf("failed to write debug kitty log %s: %v", path, err) + } +} + // Attachment holds data for an email attachment. type Attachment struct { Filename string @@ -1162,18 +1191,9 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } if os.Getenv("DEBUG_KITTY_IMAGES") != "" { msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID) - log.Print(msg) + loglevel.Infof("%s", strings.TrimSuffix(msg, "\n")) if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" { - // Use a closure with defer so a panic between open and - // WriteString doesn't leak the file descriptor (#894). - func() { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - _, _ = f.WriteString(msg) - }() + writeDebugKittyLog(path, msg) } } if textPartID != "" { diff --git a/fetcher/fetcher_test.go b/fetcher/fetcher_test.go index af4c379..2c42fcf 100644 --- a/fetcher/fetcher_test.go +++ b/fetcher/fetcher_test.go @@ -2,6 +2,8 @@ package fetcher import ( "bytes" + "errors" + "fmt" "strings" "testing" @@ -26,6 +28,66 @@ func (h testPartHeader) Set(key, value string) { h[key] = value } +type failingDebugKittyLogFile struct { + writeErr error + closeErr error +} + +func (f failingDebugKittyLogFile) WriteString(s string) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + return len(s), nil +} + +func (f failingDebugKittyLogFile) Close() error { + return f.closeErr +} + +func TestWriteDebugKittyLogReportsOpenWriteAndCloseErrors(t *testing.T) { + originalOpenLogFile := debugKittyOpenLogFile + originalLogErrorf := debugKittyLogErrorf + defer func() { + debugKittyOpenLogFile = originalOpenLogFile + debugKittyLogErrorf = originalLogErrorf + }() + + var logged []string + debugKittyLogErrorf = func(format string, args ...interface{}) { + logged = append(logged, fmt.Sprintf(format, args...)) + } + + openErr := errors.New("open failed") + debugKittyOpenLogFile = func(string) (debugKittyLogFile, error) { + return nil, openErr + } + writeDebugKittyLog("/tmp/matcha-kitty.log", "hello") + + writeErr := errors.New("write failed") + closeErr := errors.New("close failed") + debugKittyOpenLogFile = func(string) (debugKittyLogFile, error) { + return failingDebugKittyLogFile{ + writeErr: writeErr, + closeErr: closeErr, + }, nil + } + writeDebugKittyLog("/tmp/matcha-kitty.log", "hello") + + joined := strings.Join(logged, "\n") + for _, want := range []string{ + "failed to open debug kitty log", + openErr.Error(), + "failed to write debug kitty log", + writeErr.Error(), + "failed to close debug kitty log", + closeErr.Error(), + } { + if !strings.Contains(joined, want) { + t.Fatalf("expected logged output to contain %q, got %q", want, joined) + } + } +} + func TestDecodePartUsesCharsetWhenContentTypeIsMalformed(t *testing.T) { header := testPartHeader{} header.Set("Content-Type", "text/plain; charset=iso-8859-1; broken") diff --git a/view/html.go b/view/html.go index 7d76f64..c90e822 100644 --- a/view/html.go +++ b/view/html.go @@ -14,6 +14,7 @@ import ( "charm.land/lipgloss/v2" "github.com/floatpane/matcha/clib" "github.com/floatpane/matcha/internal/httpclient" + "github.com/floatpane/matcha/internal/loglevel" "github.com/floatpane/matcha/theme" lru "github.com/hashicorp/golang-lru/v2" ) @@ -268,22 +269,42 @@ func imageProtocolSupported() bool { weztermSupported() || waystSupported() || warpSupported() || konsoleSupported() } +type debugImageProtocolLogFile interface { + WriteString(string) (int, error) + Close() error +} + +var ( + debugImageProtocolOpenLogFile = func(path string) (debugImageProtocolLogFile, error) { + return os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + } + debugImageProtocolLogErrorf = loglevel.Infof +) + func debugImageProtocol(format string, args ...interface{}) { if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" { return } msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...) - fmt.Print(msg) + loglevel.Infof("%s", strings.TrimSuffix(msg, "\n")) if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" { - if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { - _, _ = f.WriteString(msg) - _ = f.Close() - } + writeDebugImageProtocolLog(path, msg) } else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" { - if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { - _, _ = f.WriteString(msg) - _ = f.Close() - } + writeDebugImageProtocolLog(path, msg) + } +} + +func writeDebugImageProtocolLog(path, msg string) { + f, err := debugImageProtocolOpenLogFile(path) + if err != nil { + debugImageProtocolLogErrorf("failed to open debug image protocol log %s: %v", path, err) + return + } + if _, err := f.WriteString(msg); err != nil { + debugImageProtocolLogErrorf("failed to write debug image protocol log %s: %v", path, err) + } + if err := f.Close(); err != nil { + debugImageProtocolLogErrorf("failed to close debug image protocol log %s: %v", path, err) } } diff --git a/view/html_test.go b/view/html_test.go index f9630f0..201500a 100644 --- a/view/html_test.go +++ b/view/html_test.go @@ -1,6 +1,7 @@ package view import ( + "errors" "fmt" "os" "regexp" @@ -32,6 +33,118 @@ func clearAllTerminalEnv() { os.Setenv("TERM_PROGRAM", "basic") } +type failingDebugImageProtocolLogFile struct { + writeErr error + closeErr error +} + +func (f failingDebugImageProtocolLogFile) WriteString(s string) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + return len(s), nil +} + +func (f failingDebugImageProtocolLogFile) Close() error { + return f.closeErr +} + +func TestDebugImageProtocolReportsLogWriteError(t *testing.T) { + originalOpenLogFile := debugImageProtocolOpenLogFile + originalLogErrorf := debugImageProtocolLogErrorf + defer func() { + debugImageProtocolOpenLogFile = originalOpenLogFile + debugImageProtocolLogErrorf = originalLogErrorf + }() + + writeErr := errors.New("write failed") + debugImageProtocolOpenLogFile = func(string) (debugImageProtocolLogFile, error) { + return failingDebugImageProtocolLogFile{writeErr: writeErr}, nil + } + + var logged []string + debugImageProtocolLogErrorf = func(format string, args ...interface{}) { + logged = append(logged, fmt.Sprintf(format, args...)) + } + + t.Setenv("DEBUG_IMAGE_PROTOCOL", "1") + t.Setenv("DEBUG_IMAGE_PROTOCOL_LOG", "/tmp/matcha-debug.log") + + debugImageProtocol("hello") + + joined := strings.Join(logged, "\n") + if !strings.Contains(joined, "failed to write debug image protocol log") { + t.Fatalf("expected write error to be logged, got %q", joined) + } + if !strings.Contains(joined, writeErr.Error()) { + t.Fatalf("expected logged error to contain %q, got %q", writeErr, joined) + } +} + +func TestDebugImageProtocolReportsLogCloseError(t *testing.T) { + originalOpenLogFile := debugImageProtocolOpenLogFile + originalLogErrorf := debugImageProtocolLogErrorf + defer func() { + debugImageProtocolOpenLogFile = originalOpenLogFile + debugImageProtocolLogErrorf = originalLogErrorf + }() + + closeErr := errors.New("close failed") + debugImageProtocolOpenLogFile = func(string) (debugImageProtocolLogFile, error) { + return failingDebugImageProtocolLogFile{closeErr: closeErr}, nil + } + + var logged []string + debugImageProtocolLogErrorf = func(format string, args ...interface{}) { + logged = append(logged, fmt.Sprintf(format, args...)) + } + + t.Setenv("DEBUG_IMAGE_PROTOCOL", "1") + t.Setenv("DEBUG_KITTY_LOG", "/tmp/matcha-kitty.log") + + debugImageProtocol("hello") + + joined := strings.Join(logged, "\n") + if !strings.Contains(joined, "failed to close debug image protocol log") { + t.Fatalf("expected close error to be logged, got %q", joined) + } + if !strings.Contains(joined, closeErr.Error()) { + t.Fatalf("expected logged error to contain %q, got %q", closeErr, joined) + } +} + +func TestDebugImageProtocolReportsLogOpenError(t *testing.T) { + originalOpenLogFile := debugImageProtocolOpenLogFile + originalLogErrorf := debugImageProtocolLogErrorf + defer func() { + debugImageProtocolOpenLogFile = originalOpenLogFile + debugImageProtocolLogErrorf = originalLogErrorf + }() + + openErr := errors.New("open failed") + debugImageProtocolOpenLogFile = func(string) (debugImageProtocolLogFile, error) { + return nil, openErr + } + + var logged []string + debugImageProtocolLogErrorf = func(format string, args ...interface{}) { + logged = append(logged, fmt.Sprintf(format, args...)) + } + + t.Setenv("DEBUG_IMAGE_PROTOCOL", "1") + t.Setenv("DEBUG_IMAGE_PROTOCOL_LOG", "/tmp/matcha-debug.log") + + debugImageProtocol("hello") + + joined := strings.Join(logged, "\n") + if !strings.Contains(joined, "failed to open debug image protocol log") { + t.Fatalf("expected open error to be logged, got %q", joined) + } + if !strings.Contains(joined, openErr.Error()) { + t.Fatalf("expected logged error to contain %q, got %q", openErr, joined) + } +} + func TestDecodeQuotedPrintable(t *testing.T) { testCases := []struct { name string