diff --git a/.golangci.yml b/.golangci.yml index 2b51128..e74f4ad 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -472,4 +472,5 @@ linters: - goconst - gosec - noctx + - reassign - wrapcheck diff --git a/tee/tee.go b/tee/tee.go index 1d7a1f0..00faf8e 100644 --- a/tee/tee.go +++ b/tee/tee.go @@ -17,23 +17,6 @@ type Tee struct { Verbose bool } -func (t *Tee) Write(p []byte) (int, error) { - n, err := os.Stdout.Write(p) - if err != nil { - return n, err - } - - if t.f != nil { - return t.f.Write(p) - } - return n, nil -} - -// Close calls Close on the underlying file. -func (t *Tee) Close() error { - return t.f.Close() -} - // NewOut writes to standard output only. The file is created, not // appended to. func NewOut(logFile string) (*Tee, error) { @@ -48,9 +31,32 @@ func NewOut(logFile string) (*Tee, error) { return &Tee{f: f}, nil } +func (t *Tee) Write(p []byte) (int, error) { + n, err := os.Stdout.Write(p) + if err != nil { + return n, err + } + + if t.f != nil { + return t.f.Write(p) + } + return n, nil +} + +// Close calls Close on the underlying file if present. +// It is safe to call Close on a Tee with no file; in that case, it returns nil. +func (t *Tee) Close() error { + if t == nil || t.f == nil { + return nil + } + err := t.f.Close() + t.f = nil + return err +} + // Printf formats according to a format specifier and writes to the // tee instance. -func (t *Tee) Printf(format string, args ...interface{}) (int, error) { +func (t *Tee) Printf(format string, args ...any) (int, error) { s := fmt.Sprintf(format, args...) n, err := os.Stdout.WriteString(s) if err != nil { @@ -66,7 +72,7 @@ func (t *Tee) Printf(format string, args ...interface{}) (int, error) { // VPrintf is a variant of Printf that only prints if the Tee's // Verbose flag is set. -func (t *Tee) VPrintf(format string, args ...interface{}) (int, error) { +func (t *Tee) VPrintf(format string, args ...any) (int, error) { if t.Verbose { return t.Printf(format, args...) } @@ -87,12 +93,12 @@ func Open(logFile string) error { // Printf formats according to a format specifier and writes to the // global tee. -func Printf(format string, args ...interface{}) (int, error) { +func Printf(format string, args ...any) (int, error) { return globalTee.Printf(format, args...) } // VPrintf calls VPrintf on the global tee instance. -func VPrintf(format string, args ...interface{}) (int, error) { +func VPrintf(format string, args ...any) (int, error) { return globalTee.VPrintf(format, args...) } diff --git a/tee/tee_test.go b/tee/tee_test.go new file mode 100644 index 0000000..4813885 --- /dev/null +++ b/tee/tee_test.go @@ -0,0 +1,197 @@ +package tee_test + +import ( + "io" + "os" + "path/filepath" + "testing" + + tee "git.wntrmute.dev/kyle/goutils/tee" +) + +// captureStdout redirects os.Stdout for the duration of fn and returns what was written. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + old := os.Stdout + os.Stdout = w + defer func() { os.Stdout = old }() + + fn() + + // Close writer to unblock reader and restore stdout + _ = w.Close() + b, _ := io.ReadAll(r) + _ = r.Close() + return string(b) +} + +func TestNewOutEmpty_WritesToStdoutOnly(t *testing.T) { + teeInst, err := tee.NewOut("") + if err != nil { + t.Fatalf("NewOut: %v", err) + } + + out := captureStdout(t, func() { + var n int + if n, err = teeInst.Write([]byte("abc")); err != nil || n != 3 { + t.Fatalf("Write got n=%d err=%v", n, err) + } + + if n, err = teeInst.Printf("-%d-", 7); err != nil || n != len("-7-") { + t.Fatalf("Printf got n=%d err=%v", n, err) + } + }) + + if out != "abc-7-" { + t.Fatalf("stdout = %q, want %q", out, "abc-7-") + } +} + +func TestNewOutWithFile_WritesToBoth(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "log.txt") + + teeInst, err := tee.NewOut(logPath) + if err != nil { + t.Fatalf("NewOut: %v", err) + } + defer func() { _ = teeInst.Close() }() + + out := captureStdout(t, func() { + if _, err = teeInst.Write([]byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + if _, err = teeInst.Printf("%s", "y"); err != nil { + t.Fatalf("Printf: %v", err) + } + }) + + if out != "xy" { + t.Fatalf("stdout = %q, want %q", out, "xy") + } + + // Close to flush and release the file before reading + if err = teeInst.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "xy" { + t.Fatalf("file content = %q, want %q", string(data), "xy") + } +} + +func TestVPrintf_VerboseToggle(t *testing.T) { + teeInst := &tee.Tee{} // stdout only + + out := captureStdout(t, func() { + if n, err := teeInst.VPrintf("hello"); err != nil || n != 0 { + t.Fatalf("VPrintf (quiet) got n=%d err=%v", n, err) + } + }) + if out != "" { + t.Fatalf("stdout = %q, want empty when not verbose", out) + } + + teeInst.Verbose = true + out = captureStdout(t, func() { + if n, err := teeInst.VPrintf("%s", "hello"); err != nil || n != len("hello") { + t.Fatalf("VPrintf (verbose) got n=%d err=%v", n, err) + } + }) + if out != "hello" { + t.Fatalf("stdout = %q, want %q", out, "hello") + } +} + +func TestWrite_StdoutErrorDoesNotWriteToFile(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "log.txt") + teeInst, err := tee.NewOut(logPath) + if err != nil { + t.Fatalf("NewOut: %v", err) + } + defer func() { _ = teeInst.Close() }() + + // Replace stdout with a closed pipe writer to force write error. + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + old := os.Stdout + os.Stdout = w + _ = w.Close() // immediately close to cause EPIPE on write + defer func() { + os.Stdout = old + _ = r.Close() + }() + + var n int + if n, err = teeInst.Write([]byte("abc")); err == nil { + t.Fatalf("expected error writing to closed stdout, got n=%d err=nil", n) + } + + // Ensure file remained empty because stdout write failed first. + _ = teeInst.Close() + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if len(data) != 0 { + t.Fatalf("file content = %q, want empty due to stdout failure", string(data)) + } +} + +func TestGlobal_OpenPrintfVPrintfClose(t *testing.T) { + // Ensure a clean slate for global tee + _ = tee.Close() + tee.SetVerbose(false) + + dir := t.TempDir() + logPath := filepath.Join(dir, "glog.txt") + + if err := tee.Open(logPath); err != nil { + t.Fatalf("Open: %v", err) + } + + out := captureStdout(t, func() { + if _, err := tee.Printf("A"); err != nil { + t.Fatalf("Printf: %v", err) + } + // Not verbose yet, should not print + if n, err := tee.VPrintf("B"); err != nil || n != 0 { + t.Fatalf("VPrintf (quiet) n=%d err=%v", n, err) + } + tee.SetVerbose(true) + if _, err := tee.VPrintf("C%d", 1); err != nil { + t.Fatalf("VPrintf (verbose): %v", err) + } + }) + + if out != "AC1" { + t.Fatalf("stdout = %q, want %q", out, "AC1") + } + + if err := tee.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "AC1" { + t.Fatalf("file content = %q, want %q", string(data), "AC1") + } + + // Reset global tee for other tests/packages + _ = tee.Close() + tee.SetVerbose(false) +}