From cdb7ac1a3b7999e2633de96bfa0329210394f734 Mon Sep 17 00:00:00 2001 From: Jaap de Haan <261428+jdehaan@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:10:09 +0000 Subject: [PATCH] refactor: use log/slog --- backup/cmd/check-coverage.go | 3 +- backup/cmd/job.go | 8 +-- backup/cmd/run.go | 4 +- backup/cmd/simulate.go | 4 +- backup/internal/check.go | 30 +++++----- backup/internal/config.go | 10 ++-- backup/internal/helper.go | 37 +++++------- backup/internal/job_command.go | 6 +- backup/internal/rsync.go | 10 ++-- backup/internal/rsync_list.go | 6 +- backup/internal/test/check_test.go | 6 +- backup/internal/test/config_test.go | 5 +- backup/internal/test/helper_test.go | 63 ++------------------ backup/internal/test/mock_jobcommand_test.go | 26 ++++---- backup/internal/testutil/logger.go | 20 +++++++ 15 files changed, 98 insertions(+), 140 deletions(-) create mode 100644 backup/internal/testutil/logger.go diff --git a/backup/cmd/check-coverage.go b/backup/cmd/check-coverage.go index 4e440ce..d10d39e 100644 --- a/backup/cmd/check-coverage.go +++ b/backup/cmd/check-coverage.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "log/slog" "os" "backup-rsync/backup/internal" @@ -24,7 +25,7 @@ func buildCheckCoverageCommand(fs afero.Fs) *cobra.Command { } checker := &internal.CoverageChecker{ - Logger: internal.NewUTCLogger(os.Stderr), + Logger: slog.New(internal.NewUTCTextHandler(os.Stderr)), Fs: fs, } diff --git a/backup/cmd/job.go b/backup/cmd/job.go index 47c4b31..a2cb847 100644 --- a/backup/cmd/job.go +++ b/backup/cmd/job.go @@ -4,7 +4,7 @@ import ( "backup-rsync/backup/internal" "fmt" "io" - "log" + "log/slog" "strings" "time" @@ -13,10 +13,10 @@ import ( ) // LoggerFactory creates a logger, returning the logger, log directory path, cleanup function, and any error. -type LoggerFactory func(fs afero.Fs, configPath string, now time.Time) (*log.Logger, string, func() error, error) +type LoggerFactory func(fs afero.Fs, configPath string, now time.Time) (*slog.Logger, string, func() error, error) -func discardLoggerFactory(_ afero.Fs, _ string, _ time.Time) (*log.Logger, string, func() error, error) { - return log.New(io.Discard, "", 0), "", func() error { return nil }, nil +func discardLoggerFactory(_ afero.Fs, _ string, _ time.Time) (*slog.Logger, string, func() error, error) { + return slog.New(slog.DiscardHandler), "", func() error { return nil }, nil } type jobCommandOptions struct { diff --git a/backup/cmd/run.go b/backup/cmd/run.go index fae60fe..a76affd 100644 --- a/backup/cmd/run.go +++ b/backup/cmd/run.go @@ -3,7 +3,7 @@ package cmd import ( "backup-rsync/backup/internal" "io" - "log" + "log/slog" "time" "github.com/spf13/afero" @@ -14,7 +14,7 @@ func buildRunCommand(fs afero.Fs, shell internal.Exec) *cobra.Command { return buildJobCommand(fs, jobCommandOptions{ use: "run", short: "Execute the sync jobs", - createLogger: func(fs afero.Fs, configPath string, now time.Time) (*log.Logger, string, func() error, error) { + createLogger: func(fs afero.Fs, configPath string, now time.Time) (*slog.Logger, string, func() error, error) { logPath := internal.GetLogPath(configPath, now) logger, cleanup, err := internal.CreateMainLogger(fs, logPath) diff --git a/backup/cmd/simulate.go b/backup/cmd/simulate.go index dac1a54..bb4f0dc 100644 --- a/backup/cmd/simulate.go +++ b/backup/cmd/simulate.go @@ -3,7 +3,7 @@ package cmd import ( "backup-rsync/backup/internal" "io" - "log" + "log/slog" "time" "github.com/spf13/afero" @@ -14,7 +14,7 @@ func buildSimulateCommand(fs afero.Fs, shell internal.Exec) *cobra.Command { return buildJobCommand(fs, jobCommandOptions{ use: "simulate", short: "Simulate the sync jobs", - createLogger: func(fs afero.Fs, configPath string, now time.Time) (*log.Logger, string, func() error, error) { + createLogger: func(fs afero.Fs, configPath string, now time.Time) (*slog.Logger, string, func() error, error) { logPath := internal.GetLogPath(configPath, now) + "-sim" logger, cleanup, err := internal.CreateMainLogger(fs, logPath) diff --git a/backup/internal/check.go b/backup/internal/check.go index bfb1da5..48884b0 100644 --- a/backup/internal/check.go +++ b/backup/internal/check.go @@ -2,7 +2,7 @@ package internal import ( "fmt" - "log" + "log/slog" "path/filepath" "slices" "strings" @@ -12,7 +12,7 @@ import ( // CoverageChecker analyzes path coverage against a configuration. type CoverageChecker struct { - Logger *log.Logger + Logger *slog.Logger Fs afero.Fs } @@ -21,8 +21,8 @@ func (c *CoverageChecker) IsExcludedGlobally(path string, mappings []Mapping) bo for _, exclusion := range mapping.Exclusions { exclusionPath := filepath.Join(mapping.Source, exclusion) if strings.HasPrefix(NormalizePath(path), exclusionPath) { - c.Logger.Printf("EXCLUDED: Path '%s' is globally excluded by '%s' in source '%s'", - path, exclusion, mapping.Source) + c.Logger.Info(fmt.Sprintf("EXCLUDED: Path '%s' is globally excluded by '%s' in source '%s'", + path, exclusion, mapping.Source)) return true } @@ -56,13 +56,13 @@ func (c *CoverageChecker) isExcluded(path string, job Job) bool { func (c *CoverageChecker) isCoveredByJob(path string, job Job) bool { if NormalizePath(job.Source) == NormalizePath(path) { - c.Logger.Printf("COVERED: Path '%s' is covered by job '%s'", path, job.Name) + c.Logger.Info(fmt.Sprintf("COVERED: Path '%s' is covered by job '%s'", path, job.Name)) return true } if c.isExcluded(path, job) { - c.Logger.Printf("EXCLUDED: Path '%s' is excluded by job '%s'", path, job.Name) + c.Logger.Info(fmt.Sprintf("EXCLUDED: Path '%s' is excluded by job '%s'", path, job.Name)) return true } @@ -86,7 +86,7 @@ func (c *CoverageChecker) checkPath( path string, mappings []Mapping, result *[]string, seen map[string]bool, ) { if seen[path] { - c.Logger.Printf("SKIP: Path '%s' already seen", path) + c.Logger.Info(fmt.Sprintf("SKIP: Path '%s' already seen", path)) return } @@ -95,27 +95,27 @@ func (c *CoverageChecker) checkPath( // Skip if globally excluded if c.IsExcludedGlobally(path, mappings) { - c.Logger.Printf("SKIP: Path '%s' is globally excluded", path) + c.Logger.Info(fmt.Sprintf("SKIP: Path '%s' is globally excluded", path)) return } // Skip if covered by a job if c.isCovered(path, mappings) { - c.Logger.Printf("SKIP: Path '%s' is covered by a job", path) + c.Logger.Info(fmt.Sprintf("SKIP: Path '%s' is covered by a job", path)) return } // Check if it's effectively covered through descendants if c.isEffectivelyCovered(path, mappings) { - c.Logger.Printf("SKIP: Path '%s' is effectively covered", path) + c.Logger.Info(fmt.Sprintf("SKIP: Path '%s' is effectively covered", path)) return } // Add uncovered path - c.Logger.Printf("ADD: Path '%s' is uncovered", path) + c.Logger.Info(fmt.Sprintf("ADD: Path '%s' is uncovered", path)) *result = append(*result, path) } @@ -124,13 +124,13 @@ func (c *CoverageChecker) checkPath( func (c *CoverageChecker) isEffectivelyCovered(path string, mappings []Mapping) bool { children, err := getChildDirectories(c.Fs, path) if err != nil { - c.Logger.Printf("ERROR: could not get child directories of '%s': %v", path, err) + c.Logger.Info(fmt.Sprintf("ERROR: could not get child directories of '%s': %v", path, err)) return false } if len(children) == 0 { - c.Logger.Printf("NOT COVERED: Path '%s' has no children", path) + c.Logger.Info(fmt.Sprintf("NOT COVERED: Path '%s' has no children", path)) return false // Leaf directories are not effectively covered unless directly covered } @@ -141,14 +141,14 @@ func (c *CoverageChecker) isEffectivelyCovered(path string, mappings []Mapping) covered := c.IsExcludedGlobally(child, mappings) || c.isCovered(child, mappings) || c.isEffectivelyCovered(child, mappings) if !covered { - c.Logger.Printf("UNCOVERED CHILD: Path '%s' has uncovered child '%s'", path, child) + c.Logger.Info(fmt.Sprintf("UNCOVERED CHILD: Path '%s' has uncovered child '%s'", path, child)) allCovered = false } } if allCovered { - c.Logger.Printf("COVERED: Path '%s' is effectively covered", path) + c.Logger.Info(fmt.Sprintf("COVERED: Path '%s' is effectively covered", path)) } return allCovered diff --git a/backup/internal/config.go b/backup/internal/config.go index 721adca..205d830 100644 --- a/backup/internal/config.go +++ b/backup/internal/config.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" "io" - "log" + "log/slog" "maps" "os" "path/filepath" @@ -71,13 +71,13 @@ func (cfg Config) String() string { return string(out) } -func (cfg Config) Apply(rsync JobCommand, logger *log.Logger) error { +func (cfg Config) Apply(rsync JobCommand, logger *slog.Logger) error { versionInfo, fullpath, err := rsync.GetVersionInfo() if err != nil { - logger.Printf("Failed to fetch rsync version: %v", err) + logger.Info(fmt.Sprintf("Failed to fetch rsync version: %v", err)) } else { - logger.Printf("Rsync Binary Path: %s", fullpath) - logger.Printf("Rsync Version Info: %s", versionInfo) + logger.Info("Rsync Binary Path: " + fullpath) + logger.Info("Rsync Version Info: " + versionInfo) } counts := make(map[JobStatus]int) diff --git a/backup/internal/helper.go b/backup/internal/helper.go index ac5cde7..6bd272c 100644 --- a/backup/internal/helper.go +++ b/backup/internal/helper.go @@ -4,7 +4,7 @@ package internal import ( "fmt" "io" - "log" + "log/slog" "os" "path/filepath" "strings" @@ -13,26 +13,17 @@ import ( "github.com/spf13/afero" ) -// UTCLogWriter wraps an io.Writer and prepends an ISO 8601 UTC timestamp to each write. -type UTCLogWriter struct { - W io.Writer - Now func() time.Time -} - -func (u *UTCLogWriter) Write(data []byte) (int, error) { - now := u.Now().UTC().Format(time.RFC3339) - - _, err := fmt.Fprintf(u.W, "%s %s", now, data) - if err != nil { - return 0, fmt.Errorf("writing log entry: %w", err) - } - - return len(data), nil -} - -// NewUTCLogger creates a *log.Logger that writes ISO 8601 UTC timestamps. -func NewUTCLogger(w io.Writer) *log.Logger { - return log.New(&UTCLogWriter{W: w, Now: time.Now}, "", 0) +// NewUTCTextHandler creates a slog.Handler that writes text logs with UTC timestamps. +func NewUTCTextHandler(w io.Writer) slog.Handler { + return slog.NewTextHandler(w, &slog.HandlerOptions{ + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + a.Value = slog.StringValue(a.Value.Time().UTC().Format(time.RFC3339)) + } + + return a + }, + }) } func NormalizePath(path string) string { @@ -51,7 +42,7 @@ func GetLogPath(configPath string, now time.Time) string { func CreateMainLogger( fs afero.Fs, logPath string, -) (*log.Logger, func() error, error) { +) (*slog.Logger, func() error, error) { overallLogPath := logPath + "/summary.log" err := fs.MkdirAll(logPath, LogDirPermission) @@ -64,7 +55,7 @@ func CreateMainLogger( return nil, nil, fmt.Errorf("failed to open overall log file: %w", err) } - logger := NewUTCLogger(overallLogFile) + logger := slog.New(NewUTCTextHandler(overallLogFile)) cleanup := func() error { return overallLogFile.Close() diff --git a/backup/internal/job_command.go b/backup/internal/job_command.go index 8441463..63bd7da 100644 --- a/backup/internal/job_command.go +++ b/backup/internal/job_command.go @@ -1,6 +1,6 @@ package internal -import "log" +import "log/slog" // JobStatus represents the outcome of a job execution. type JobStatus string @@ -18,6 +18,6 @@ const ( type JobCommand interface { Run(job Job) JobStatus GetVersionInfo() (string, string, error) - ReportJobStatus(jobName string, status JobStatus, logger *log.Logger) - ReportSummary(counts map[JobStatus]int, logger *log.Logger) + ReportJobStatus(jobName string, status JobStatus, logger *slog.Logger) + ReportSummary(counts map[JobStatus]int, logger *slog.Logger) } diff --git a/backup/internal/rsync.go b/backup/internal/rsync.go index 3e439ae..cd17ec5 100644 --- a/backup/internal/rsync.go +++ b/backup/internal/rsync.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" "io" - "log" + "log/slog" "os" "path/filepath" "strings" @@ -43,15 +43,15 @@ func (c SharedCommand) PrintArgs(job Job, args []string) { fmt.Fprintf(c.Output, "Command: %s %s\n", c.BinPath, strings.Join(args, " ")) } -func (c SharedCommand) ReportJobStatus(jobName string, status JobStatus, logger *log.Logger) { - logger.Printf("STATUS [%s]: %s", jobName, status) +func (c SharedCommand) ReportJobStatus(jobName string, status JobStatus, logger *slog.Logger) { + logger.Info(fmt.Sprintf("STATUS [%s]: %s", jobName, status)) fmt.Fprintf(c.Output, "Status [%s]: %s\n", jobName, status) } -func (c SharedCommand) ReportSummary(counts map[JobStatus]int, logger *log.Logger) { +func (c SharedCommand) ReportSummary(counts map[JobStatus]int, logger *slog.Logger) { summary := fmt.Sprintf("Summary: %d succeeded, %d failed, %d skipped", counts[Success], counts[Failure], counts[Skipped]) - logger.Print(summary) + logger.Info(summary) fmt.Fprintln(c.Output, summary) } diff --git a/backup/internal/rsync_list.go b/backup/internal/rsync_list.go index bf3f97b..5072361 100644 --- a/backup/internal/rsync_list.go +++ b/backup/internal/rsync_list.go @@ -2,7 +2,7 @@ package internal import ( "io" - "log" + "log/slog" ) // ListCommand prints the rsync commands that would be executed without running them. @@ -17,9 +17,9 @@ func NewListCommand(binPath string, shell Exec, output io.Writer) ListCommand { } } -func (ListCommand) ReportJobStatus(_ string, _ JobStatus, _ *log.Logger) {} +func (ListCommand) ReportJobStatus(_ string, _ JobStatus, _ *slog.Logger) {} -func (ListCommand) ReportSummary(_ map[JobStatus]int, _ *log.Logger) {} +func (ListCommand) ReportSummary(_ map[JobStatus]int, _ *slog.Logger) {} func (c ListCommand) Run(job Job) JobStatus { logPath := c.JobLogPath(job) diff --git a/backup/internal/test/check_test.go b/backup/internal/test/check_test.go index 550b6c4..3eac40b 100644 --- a/backup/internal/test/check_test.go +++ b/backup/internal/test/check_test.go @@ -3,12 +3,12 @@ package internal_test import ( "bytes" "io" - "log" "path/filepath" "sort" "testing" . "backup-rsync/backup/internal" + "backup-rsync/backup/internal/testutil" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -16,14 +16,14 @@ import ( func newTestChecker(fs afero.Fs, logBuf *bytes.Buffer) *CoverageChecker { return &CoverageChecker{ - Logger: log.New(logBuf, "", 0), + Logger: testutil.NewTestLogger(logBuf), Fs: fs, } } func newSilentChecker(fs afero.Fs) *CoverageChecker { return &CoverageChecker{ - Logger: log.New(io.Discard, "", 0), + Logger: testutil.NewTestLogger(io.Discard), Fs: fs, } } diff --git a/backup/internal/test/config_test.go b/backup/internal/test/config_test.go index 47a9501..7c5e5eb 100644 --- a/backup/internal/test/config_test.go +++ b/backup/internal/test/config_test.go @@ -2,7 +2,6 @@ package internal_test import ( "bytes" - "log" "testing" "github.com/stretchr/testify/assert" @@ -277,7 +276,7 @@ func TestConfigApply_VersionInfoSuccess(t *testing.T) { var logBuf bytes.Buffer - logger := log.New(&logBuf, "", 0) + logger := testutil.NewTestLogger(&logBuf) cfg := Config{ Mappings: []Mapping{ @@ -311,7 +310,7 @@ func TestConfigApply_VersionInfoError(t *testing.T) { var logBuf bytes.Buffer - logger := log.New(&logBuf, "", 0) + logger := testutil.NewTestLogger(&logBuf) cfg := Config{ Mappings: []Mapping{ diff --git a/backup/internal/test/helper_test.go b/backup/internal/test/helper_test.go index 7d1fd40..4e848a3 100644 --- a/backup/internal/test/helper_test.go +++ b/backup/internal/test/helper_test.go @@ -2,7 +2,7 @@ package internal_test import ( "bytes" - "errors" + "log/slog" "testing" "time" @@ -110,67 +110,14 @@ func TestGetLogPath(t *testing.T) { } } -func TestUTCLogWriter_FormatsISO8601UTC(t *testing.T) { +func TestNewUTCTextHandler_FormatsUTCTimestamp(t *testing.T) { var buf bytes.Buffer - writer := &UTCLogWriter{ - W: &buf, - Now: fixedTime, - } - - _, err := writer.Write([]byte("hello\n")) - require.NoError(t, err) - - assert.Equal(t, "2025-06-15T14:30:45Z hello\n", buf.String()) -} - -func TestUTCLogWriter_ConvertsToUTC(t *testing.T) { - var buf bytes.Buffer - - eastern := time.FixedZone("EST", -5*60*60) - nonUTCTime := time.Date(2025, 6, 15, 10, 0, 0, 0, eastern) - - writer := &UTCLogWriter{ - W: &buf, - Now: func() time.Time { - return nonUTCTime - }, - } - - _, err := writer.Write([]byte("test\n")) - require.NoError(t, err) - - assert.Equal(t, "2025-06-15T15:00:00Z test\n", buf.String()) -} - -var errWriteFailed = errors.New("write failed") - -type failWriter struct{} - -func (f *failWriter) Write(_ []byte) (int, error) { - return 0, errWriteFailed -} - -func TestUTCLogWriter_PropagatesWriteError(t *testing.T) { - writer := &UTCLogWriter{ - W: &failWriter{}, - Now: fixedTime, - } - - _, err := writer.Write([]byte("hello\n")) - - require.ErrorIs(t, err, errWriteFailed) -} - -func TestNewUTCLogger_WritesISO8601(t *testing.T) { - var buf bytes.Buffer - - logger := NewUTCLogger(&buf) + logger := slog.New(NewUTCTextHandler(&buf)) - logger.Print("test message") + logger.Info("test message") output := buf.String() - // Should contain ISO 8601 timestamp format (RFC3339) and the message assert.Contains(t, output, "test message") - assert.Regexp(t, `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z `, output) + assert.Regexp(t, `time=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, output) } diff --git a/backup/internal/test/mock_jobcommand_test.go b/backup/internal/test/mock_jobcommand_test.go index 68f6a16..8f0e6d4 100644 --- a/backup/internal/test/mock_jobcommand_test.go +++ b/backup/internal/test/mock_jobcommand_test.go @@ -6,7 +6,7 @@ package internal_test import ( "backup-rsync/backup/internal" - "log" + "log/slog" mock "github.com/stretchr/testify/mock" ) @@ -98,7 +98,7 @@ func (_c *MockJobCommand_GetVersionInfo_Call) RunAndReturn(run func() (string, s } // ReportJobStatus provides a mock function for the type MockJobCommand -func (_mock *MockJobCommand) ReportJobStatus(jobName string, status internal.JobStatus, logger *log.Logger) { +func (_mock *MockJobCommand) ReportJobStatus(jobName string, status internal.JobStatus, logger *slog.Logger) { _mock.Called(jobName, status, logger) return } @@ -111,12 +111,12 @@ type MockJobCommand_ReportJobStatus_Call struct { // ReportJobStatus is a helper method to define mock.On call // - jobName string // - status internal.JobStatus -// - logger *log.Logger +// - logger *slog.Logger func (_e *MockJobCommand_Expecter) ReportJobStatus(jobName interface{}, status interface{}, logger interface{}) *MockJobCommand_ReportJobStatus_Call { return &MockJobCommand_ReportJobStatus_Call{Call: _e.mock.On("ReportJobStatus", jobName, status, logger)} } -func (_c *MockJobCommand_ReportJobStatus_Call) Run(run func(jobName string, status internal.JobStatus, logger *log.Logger)) *MockJobCommand_ReportJobStatus_Call { +func (_c *MockJobCommand_ReportJobStatus_Call) Run(run func(jobName string, status internal.JobStatus, logger *slog.Logger)) *MockJobCommand_ReportJobStatus_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { @@ -126,9 +126,9 @@ func (_c *MockJobCommand_ReportJobStatus_Call) Run(run func(jobName string, stat if args[1] != nil { arg1 = args[1].(internal.JobStatus) } - var arg2 *log.Logger + var arg2 *slog.Logger if args[2] != nil { - arg2 = args[2].(*log.Logger) + arg2 = args[2].(*slog.Logger) } run( arg0, @@ -144,13 +144,13 @@ func (_c *MockJobCommand_ReportJobStatus_Call) Return() *MockJobCommand_ReportJo return _c } -func (_c *MockJobCommand_ReportJobStatus_Call) RunAndReturn(run func(jobName string, status internal.JobStatus, logger *log.Logger)) *MockJobCommand_ReportJobStatus_Call { +func (_c *MockJobCommand_ReportJobStatus_Call) RunAndReturn(run func(jobName string, status internal.JobStatus, logger *slog.Logger)) *MockJobCommand_ReportJobStatus_Call { _c.Run(run) return _c } // ReportSummary provides a mock function for the type MockJobCommand -func (_mock *MockJobCommand) ReportSummary(counts map[internal.JobStatus]int, logger *log.Logger) { +func (_mock *MockJobCommand) ReportSummary(counts map[internal.JobStatus]int, logger *slog.Logger) { _mock.Called(counts, logger) return } @@ -162,20 +162,20 @@ type MockJobCommand_ReportSummary_Call struct { // ReportSummary is a helper method to define mock.On call // - counts map[internal.JobStatus]int -// - logger *log.Logger +// - logger *slog.Logger func (_e *MockJobCommand_Expecter) ReportSummary(counts interface{}, logger interface{}) *MockJobCommand_ReportSummary_Call { return &MockJobCommand_ReportSummary_Call{Call: _e.mock.On("ReportSummary", counts, logger)} } -func (_c *MockJobCommand_ReportSummary_Call) Run(run func(counts map[internal.JobStatus]int, logger *log.Logger)) *MockJobCommand_ReportSummary_Call { +func (_c *MockJobCommand_ReportSummary_Call) Run(run func(counts map[internal.JobStatus]int, logger *slog.Logger)) *MockJobCommand_ReportSummary_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 map[internal.JobStatus]int if args[0] != nil { arg0 = args[0].(map[internal.JobStatus]int) } - var arg1 *log.Logger + var arg1 *slog.Logger if args[1] != nil { - arg1 = args[1].(*log.Logger) + arg1 = args[1].(*slog.Logger) } run( arg0, @@ -190,7 +190,7 @@ func (_c *MockJobCommand_ReportSummary_Call) Return() *MockJobCommand_ReportSumm return _c } -func (_c *MockJobCommand_ReportSummary_Call) RunAndReturn(run func(counts map[internal.JobStatus]int, logger *log.Logger)) *MockJobCommand_ReportSummary_Call { +func (_c *MockJobCommand_ReportSummary_Call) RunAndReturn(run func(counts map[internal.JobStatus]int, logger *slog.Logger)) *MockJobCommand_ReportSummary_Call { _c.Run(run) return _c } diff --git a/backup/internal/testutil/logger.go b/backup/internal/testutil/logger.go new file mode 100644 index 0000000..b409fca --- /dev/null +++ b/backup/internal/testutil/logger.go @@ -0,0 +1,20 @@ +package testutil + +import ( + "io" + "log/slog" +) + +// NewTestLogger creates a *slog.Logger that writes to w without timestamps, +// suitable for test assertions. +func NewTestLogger(w io.Writer) *slog.Logger { + return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{ + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{} + } + + return a + }, + })) +}