diff --git a/.gitignore b/.gitignore index 899afcf..3bcd62e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ # Coverage file coverage.txt coverage.out +coverage.html # Go workspace file go.work diff --git a/README.md b/README.md index 90bf5e9..be3fac0 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ The `utc` package provides an enhanced alias of Go's `time.Time` that ensures yo ## Features - Guaranteed UTC time handling -- JSON marshaling/unmarshaling support -- SQL database compatibility +- JSON marshaling/unmarshaling support with flexible parsing +- SQL database compatibility with enhanced type support - Automatic timezone handling (PST/PDT, EST/EDT, etc.) - Extensive formatting options: - US date formats (MM/DD/YYYY) @@ -29,6 +29,10 @@ The `utc` package provides an enhanced alias of Go's `time.Time` that ensures yo - Common components (weekday, month, etc.) - Timezone conversion methods with fallback support - Full compatibility with Go's standard `time.Time` methods +- Nil-safe operations that return errors instead of panicking +- Debug mode with detailed logging for development +- Text encoding support for broader codec compatibility +- Unix timestamp helpers and day boundary utilities ## Installation @@ -38,6 +42,8 @@ To install the `utc` package, use the following command: go get github.com/agentstation/utc ``` +**Requirements**: Go 1.18 or later (uses `any` type and other modern Go features) + ## Usage 1. Import the package: @@ -109,6 +115,45 @@ type Record struct { } ``` +## Debug Mode + +The package includes a debug mode that helps identify potential bugs during development: + +```sh +# Build with debug mode enabled +go build -tags debug + +# Run tests with debug mode +go test -tags debug ./... +``` + +When debug mode is enabled, the package logs warnings when methods are called on nil receivers: + +``` +[UTC DEBUG] 2024/01/02 15:04:05 debug.go:26: String() called on nil *Time receiver +[UTC DEBUG] 2024/01/02 15:04:05 debug.go:26: Value() called on nil *Time receiver +``` + +## Additional Utilities + +The package includes several convenience methods: + +```go +// Unix timestamp conversions +t1 := utc.FromUnix(1704199445) // From Unix seconds +t2 := utc.FromUnixMilli(1704199445000) // From Unix milliseconds +seconds := t.Unix() // To Unix seconds +millis := t.UnixMilli() // To Unix milliseconds + +// Day boundaries +start := t.StartOfDay() // 2024-01-02 00:00:00.000000000 UTC +end := t.EndOfDay() // 2024-01-02 23:59:59.999999999 UTC + +// Generic timezone conversion +eastern, err := t.In("America/New_York") +tokyo, err := t.In("Asia/Tokyo") +``` + diff --git a/debug.go b/debug.go new file mode 100644 index 0000000..2b5a009 --- /dev/null +++ b/debug.go @@ -0,0 +1,27 @@ +//go:build debug +// +build debug + +package utc + +import ( + "log" + "os" + "sync" +) + +// debugLogger is only available in debug builds +var ( + debugLogger *log.Logger + debugOnce sync.Once +) + +func initDebugLogger() { + debugOnce.Do(func() { + debugLogger = log.New(os.Stderr, "[UTC DEBUG] ", log.Ldate|log.Ltime|log.Lshortfile) + }) +} + +func debugLog(format string, v ...any) { + initDebugLogger() + debugLogger.Printf(format, v...) +} diff --git a/debug_test.go b/debug_test.go new file mode 100644 index 0000000..3771a68 --- /dev/null +++ b/debug_test.go @@ -0,0 +1,69 @@ +//go:build debug +// +build debug + +package utc + +import ( + "bytes" + "os" + "strings" + "testing" +) + +// TestDebugLogging verifies that debug logging works when enabled +func TestDebugLogging(t *testing.T) { + // Capture stderr + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + // Test nil receiver calls + var nilTime *Time + + // These should log to stderr in debug mode + _ = nilTime.String() + _, _ = nilTime.Value() + _, _ = nilTime.MarshalJSON() + + // Restore stderr and read output + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + // Verify debug logs were written + expectedLogs := []string{ + "String() called on nil *Time receiver", + "Value() called on nil *Time receiver", + "MarshalJSON() called on nil *Time receiver", + } + + for _, expected := range expectedLogs { + if !strings.Contains(output, expected) { + t.Errorf("Expected debug log containing %q, but got: %s", expected, output) + } + } + + // Verify the format includes timestamp and file info + if !strings.Contains(output, "[UTC DEBUG]") { + t.Error("Debug logs should include [UTC DEBUG] prefix") + } +} + +// TestDebugLogDirect tests the debugLog function directly +func TestDebugLogDirect(t *testing.T) { + // This test just verifies debugLog doesn't panic and initializes correctly + // The actual output verification is done in TestDebugLogging which captures + // output in a more controlled way by testing real usage patterns + + // This should not panic + debugLog("test message: %s", "hello") + debugLog("another test") + + // Verify debugLogger is initialized (it should be after first call) + if debugLogger == nil { + t.Error("debugLogger should be initialized after debugLog calls") + } +} diff --git a/go.mod b/go.mod index dbcf176..9a9e94d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/agentstation/utc -go 1.22.5 +go 1.18 diff --git a/nodebug.go b/nodebug.go new file mode 100644 index 0000000..b084704 --- /dev/null +++ b/nodebug.go @@ -0,0 +1,9 @@ +//go:build !debug +// +build !debug + +package utc + +// debugLog is a no-op in non-debug builds +func debugLog(format string, v ...any) { + // No-op in production builds +} diff --git a/utc.go b/utc.go index fe0e270..088f4c4 100644 --- a/utc.go +++ b/utc.go @@ -1,9 +1,30 @@ +// Package utc provides a time.Time wrapper that ensures all times are in UTC. +// +// The package offers enhanced safety by gracefully handling nil receivers instead +// of panicking, making it more suitable for production environments. When compiled +// with the debug build tag (-tags debug), it provides additional logging for nil +// receiver method calls to help identify potential bugs during development. +// +// Key features: +// - All times are automatically converted to and stored in UTC +// - JSON marshaling/unmarshaling with flexible parsing +// - SQL database compatibility +// - Timezone conversion helpers with automatic DST handling +// - Extensive formatting options for US and EU date formats +// - Nil-safe operations that return errors instead of panicking +// +// Debug mode: +// +// To enable debug logging, compile with: go build -tags debug +// This will log warnings when methods are called on nil receivers. package utc import ( "database/sql/driver" + "encoding" "errors" "fmt" + "sync" "time" ) @@ -41,34 +62,43 @@ var ( mountainLocation *time.Location // Initialize locations - locationError = initializeLocations() + locationError = initLocations() ) -// initializeLocations loads all time zone locations at startup -func initializeLocations() error { - var err error +// initLocations loads all time zone locations at startup +// Use lazy initialization to avoid upfront tz loading cost, while preserving +// compatibility via locationError observation. +var tzOnce sync.Once +var tzInitErr error - pacificLocation, err = time.LoadLocation("America/Los_Angeles") - if err != nil { - return fmt.Errorf("failed to load Pacific timezone: %w", err) - } - - easternLocation, err = time.LoadLocation("America/New_York") - if err != nil { - return fmt.Errorf("failed to load Eastern timezone: %w", err) - } +func initLocations() error { + tzOnce.Do(func() { + var err error + pacificLocation, err = time.LoadLocation("America/Los_Angeles") + if err != nil { + tzInitErr = fmt.Errorf("failed to load Pacific timezone: %w", err) + return + } - centralLocation, err = time.LoadLocation("America/Chicago") - if err != nil { - return fmt.Errorf("failed to load Central timezone: %w", err) - } + easternLocation, err = time.LoadLocation("America/New_York") + if err != nil { + tzInitErr = fmt.Errorf("failed to load Eastern timezone: %w", err) + return + } - mountainLocation, err = time.LoadLocation("America/Denver") - if err != nil { - return fmt.Errorf("failed to load Mountain timezone: %w", err) - } + centralLocation, err = time.LoadLocation("America/Chicago") + if err != nil { + tzInitErr = fmt.Errorf("failed to load Central timezone: %w", err) + return + } - return nil + mountainLocation, err = time.LoadLocation("America/Denver") + if err != nil { + tzInitErr = fmt.Errorf("failed to load Mountain timezone: %w", err) + return + } + }) + return tzInitErr } // ValidateTimezoneAvailability checks if all timezone locations were properly initialized @@ -140,8 +170,8 @@ func (t *Time) UnmarshalJSON(data []byte) error { data = data[1 : len(data)-1] } - // Parse the time - parsedTime, err := time.Parse(time.RFC3339, string(data)) + // Parse the time (allow a few flexible formats) + parsedTime, err := parse(string(data)) if err != nil { return err } @@ -152,49 +182,92 @@ func (t *Time) UnmarshalJSON(data []byte) error { } // MarshalJSON implements the json.Marshaler interface for utc.Time. +// Returns an error for nil receivers to maintain consistency with standard marshaling behavior. func (t *Time) MarshalJSON() ([]byte, error) { if t == nil { + debugLog("MarshalJSON() called on nil *Time receiver") return nil, errors.New("cannot marshal nil utc.Time") } return []byte(`"` + t.Time.Format(time.RFC3339) + `"`), nil } +// Ensure Time implements encoding.TextMarshaler/TextUnmarshaler for broader codec support. +var ( + _ encoding.TextMarshaler = Time{} + _ encoding.TextUnmarshaler = (*Time)(nil) +) + +// MarshalText implements encoding.TextMarshaler. +func (t Time) MarshalText() ([]byte, error) { + return []byte(t.Time.Format(time.RFC3339)), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (t *Time) UnmarshalText(text []byte) error { + if len(text) == 0 { + t.Time = time.Time{} + return nil + } + parsed, err := parse(string(text)) + if err != nil { + return err + } + t.Time = parsed + return nil +} + // String implements the Stringer interface for utc.Time. It prints the time in RFC3339 format. +// +// Unlike many Go types that panic on nil receivers, this method returns "" to match +// stdlib conventions (e.g., bytes.Buffer) and improve production safety. In debug builds +// (compiled with -tags debug), nil receivers are logged to help identify potential bugs. func (t *Time) String() string { if t == nil { - panic("cannot call String() on nil utc.Time") + debugLog("String() called on nil *Time receiver") + return "" } return t.Time.Format(time.RFC3339) } // Value implements the driver.Valuer interface for database operations for utc.Time. // It returns the time.Time value and assumes the time is already in UTC. +// +// Returns an error if called on a nil receiver instead of panicking to allow graceful +// error handling in database operations. In debug builds, nil receivers are logged. func (t *Time) Value() (driver.Value, error) { if t == nil { - panic("cannot call Value() on nil utc.Time") + debugLog("Value() called on nil *Time receiver") + return nil, errors.New("cannot call Value() on nil utc.Time") } + // Preserve previous behavior: zero value still returns a non-nil time return t.Time, nil } // Scan implements the sql.Scanner interface for database operations for utc.Time // It does this by scanning the value into a time.Time, converting the time.Time to UTC, // and then assigning the UTC time to the utc.Time. -func (t *Time) Scan(value interface{}) error { +func (t *Time) Scan(value any) error { if value == nil { return errors.New("cannot scan nil into utc.Time") } - // Handle the value as a time.Time switch v := value.(type) { case time.Time: t.Time = v.UTC() return nil case string: - parsed, err := time.Parse(time.RFC3339, v) + parsed, err := parse(v) if err != nil { return err } - t.Time = parsed.UTC() + t.Time = parsed + return nil + case []byte: + parsed, err := parse(string(v)) + if err != nil { + return err + } + t.Time = parsed return nil default: return errors.New("cannot scan non-time value into utc.Time") @@ -439,3 +512,56 @@ func (t Time) TimeOnly() string { func (t Time) Kitchen() string { return t.Time.Format(time.Kitchen) } + +// Generic location helpers and utilities + +// In converts time to a named location (e.g., "America/Los_Angeles"). +func (t Time) In(name string) (time.Time, error) { + loc, err := time.LoadLocation(name) + if err != nil { + return time.Time{}, err + } + return t.Time.In(loc), nil +} + +// InLocation converts time to a provided *time.Location. +func (t Time) InLocation(loc *time.Location) time.Time { + return t.Time.In(loc) +} + +// Unix helpers +func FromUnix(sec int64) Time { return Time{time.Unix(sec, 0).UTC()} } +func FromUnixMilli(ms int64) Time { return Time{time.Unix(0, ms*int64(time.Millisecond)).UTC()} } +func (t Time) Unix() int64 { return t.Time.Unix() } +func (t Time) UnixMilli() int64 { return t.Time.UnixMilli() } + +// Day helpers - times are always in UTC within this package +func (t Time) StartOfDay() Time { + y, m, d := t.Time.UTC().Date() + return Time{time.Date(y, m, d, 0, 0, 0, 0, time.UTC)} +} + +func (t Time) EndOfDay() Time { + y, m, d := t.Time.UTC().Date() + // One nanosecond before next midnight + return Time{time.Date(y, m, d+1, 0, 0, 0, -1, time.UTC)} +} + +// Internal: parse a variety of common layouts to UTC. +func parse(s string) (time.Time, error) { + tryLayouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + var firstErr error + for _, layout := range tryLayouts { + if parsed, err := time.Parse(layout, s); err == nil { + return parsed.UTC(), nil + } else if firstErr == nil { + firstErr = err + } + } + return time.Time{}, firstErr +} diff --git a/utc_test.go b/utc_test.go index e87c6e4..493bccd 100644 --- a/utc_test.go +++ b/utc_test.go @@ -649,7 +649,7 @@ func TestUTC_ScanEdgeCases(t *testing.T) { // Test scanning various time formats tests := []struct { name string - input interface{} + input any wantErr bool }{ { @@ -790,25 +790,15 @@ func TestUTC_NilHandling(t *testing.T) { t.Errorf("MarshalJSON() on nil receiver should return nil data, got %v", data) } - // Test String() on nil - should not panic - func() { - defer func() { - if r := recover(); r == nil { - t.Error("String() on nil receiver should panic") - } - }() - _ = ut.String() - }() - - // Test Value() on nil - should not panic - func() { - defer func() { - if r := recover(); r == nil { - t.Error("Value() on nil receiver should panic") - } - }() - _, _ = ut.Value() - }() + // Test String() on nil - should not panic and return "" + if s := ut.String(); s != "" { + t.Errorf("String() on nil receiver should return \"\", got %q", s) + } + + // Test Value() on nil - should return error and nil value + if v, err := ut.Value(); err == nil || v != nil { + t.Errorf("Value() on nil receiver should return (nil, error), got (%v, %v)", v, err) + } } func TestUTC_ZeroValueHandling(t *testing.T) { @@ -978,3 +968,270 @@ func TestUTC_RFC3339(t *testing.T) { t.Errorf("RFC3339() = %q, want %q", result, expected) } } + +func TestUTC_UnmarshalText(t *testing.T) { + tests := []struct { + name string + input string + want time.Time + wantErr bool + }{ + { + name: "RFC3339 format", + input: "2024-01-02T15:04:05Z", + want: time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC), + wantErr: false, + }, + { + name: "RFC3339Nano format", + input: "2024-01-02T15:04:05.123456789Z", + want: time.Date(2024, 1, 2, 15, 4, 5, 123456789, time.UTC), + wantErr: false, + }, + { + name: "Date only format", + input: "2024-01-02", + want: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "DateTime format", + input: "2024-01-02 15:04:05", + want: time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC), + wantErr: false, + }, + { + name: "Empty input", + input: "", + want: time.Time{}, + wantErr: false, + }, + { + name: "Invalid format", + input: "invalid-time", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ut Time + err := ut.UnmarshalText([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalText() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !ut.Time.Equal(tt.want) { + t.Errorf("UnmarshalText() = %v, want %v", ut.Time, tt.want) + } + }) + } +} + +func TestUTC_MarshalText(t *testing.T) { + tests := []struct { + name string + time Time + expected string + }{ + { + name: "Normal time", + time: Time{time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC)}, + expected: "2024-01-02T15:04:05Z", + }, + { + name: "Zero time", + time: Time{}, + expected: "0001-01-01T00:00:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.time.MarshalText() + if err != nil { + t.Errorf("MarshalText() error = %v", err) + return + } + if string(result) != tt.expected { + t.Errorf("MarshalText() = %q, want %q", string(result), tt.expected) + } + }) + } +} + +func TestUTC_UnixHelpers(t *testing.T) { + // Test FromUnix + unixSec := int64(1704215045) // 2024-01-02 17:04:05 UTC + t1 := FromUnix(unixSec) + expected1 := time.Unix(unixSec, 0).UTC() + if !t1.Time.Equal(expected1) { + t.Errorf("FromUnix() = %v, want %v", t1.Time, expected1) + } + + // Test FromUnixMilli + unixMilli := int64(1704215045123) // 2024-01-02 17:04:05.123 UTC + t2 := FromUnixMilli(unixMilli) + expected2 := time.Unix(0, unixMilli*int64(time.Millisecond)).UTC() + if !t2.Time.Equal(expected2) { + t.Errorf("FromUnixMilli() = %v, want %v", t2.Time, expected2) + } + + // Test Unix() method + ut := Time{time.Date(2024, 1, 2, 17, 4, 5, 0, time.UTC)} + if ut.Unix() != 1704215045 { + t.Errorf("Unix() = %d, want %d", ut.Unix(), 1704215045) + } + + // Test UnixMilli() method + utMilli := Time{time.Date(2024, 1, 2, 17, 4, 5, 123000000, time.UTC)} + if utMilli.UnixMilli() != 1704215045123 { + t.Errorf("UnixMilli() = %d, want %d", utMilli.UnixMilli(), 1704215045123) + } +} + +func TestUTC_DayBoundaries(t *testing.T) { + // Test with a time in the middle of the day + ut := Time{time.Date(2024, 1, 2, 15, 30, 45, 123456789, time.UTC)} + + // Test StartOfDay + start := ut.StartOfDay() + expectedStart := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + if !start.Time.Equal(expectedStart) { + t.Errorf("StartOfDay() = %v, want %v", start.Time, expectedStart) + } + + // Test EndOfDay + end := ut.EndOfDay() + expectedEnd := time.Date(2024, 1, 2+1, 0, 0, 0, -1, time.UTC) // One nanosecond before next midnight + if !end.Time.Equal(expectedEnd) { + t.Errorf("EndOfDay() = %v, want %v", end.Time, expectedEnd) + } + + // Verify that start is before original time and original time is before end + if !start.Before(ut) { + t.Error("StartOfDay should be before original time") + } + if !ut.Before(end) { + t.Error("Original time should be before EndOfDay") + } +} + +func TestUTC_LocationHelpers(t *testing.T) { + ut := Time{time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC)} + + // Test In() with valid timezone + eastern, err := ut.In("America/New_York") + if err != nil { + t.Errorf("In() error = %v", err) + } + // Should be 5 hours behind UTC in winter + if eastern.Hour() != 10 { + t.Errorf("In('America/New_York') hour = %d, want 10", eastern.Hour()) + } + + // Test In() with invalid timezone + _, err = ut.In("Invalid/Timezone") + if err == nil { + t.Error("In() with invalid timezone should return error") + } + + // Test InLocation() + loc, _ := time.LoadLocation("America/Los_Angeles") + pacific := ut.InLocation(loc) + // Should be 8 hours behind UTC in winter + if pacific.Hour() != 7 { + t.Errorf("InLocation() hour = %d, want 7", pacific.Hour()) + } +} + +func TestUTC_ScanEnhanced(t *testing.T) { + var ut Time + + // Test scanning []byte + err := ut.Scan([]byte("2024-01-02T15:04:05Z")) + if err != nil { + t.Errorf("Scan([]byte) error = %v", err) + } + expected := time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC) + if !ut.Time.Equal(expected) { + t.Errorf("Scan([]byte) = %v, want %v", ut.Time, expected) + } + + // Test scanning []byte with flexible format + err = ut.Scan([]byte("2024-01-02")) + if err != nil { + t.Errorf("Scan([]byte date only) error = %v", err) + } + expectedDate := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + if !ut.Time.Equal(expectedDate) { + t.Errorf("Scan([]byte date only) = %v, want %v", ut.Time, expectedDate) + } + + // Test scanning invalid []byte + err = ut.Scan([]byte("invalid-time")) + if err == nil { + t.Error("Scan(invalid []byte) should return error") + } + + // Test scanning float64 (unsupported type) + err = ut.Scan(float64(123.45)) + if err == nil { + t.Error("Scan(float64) should return error") + } +} + +func TestUTC_InternalParse(t *testing.T) { + // Test our internal parse function through public APIs + tests := []struct { + name string + input string + want time.Time + wantErr bool + }{ + { + name: "RFC3339 with timezone", + input: "2024-01-02T15:04:05+02:00", + want: time.Date(2024, 1, 2, 13, 4, 5, 0, time.UTC), // Converted to UTC + wantErr: false, + }, + { + name: "Date-time without timezone (treated as UTC)", + input: "2024-01-02 15:04:05", + want: time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC), + wantErr: false, + }, + { + name: "Date only (treated as UTC)", + input: "2024-01-02", + want: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test through UnmarshalJSON which uses parse + var ut Time + jsonData := `"` + tt.input + `"` + err := ut.UnmarshalJSON([]byte(jsonData)) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !ut.Time.Equal(tt.want) { + t.Errorf("UnmarshalJSON() = %v, want %v", ut.Time, tt.want) + } + }) + } +} + +func TestUTC_TimezoneInitErrors(t *testing.T) { + // Test ValidateTimezoneAvailability when there's no error + if locationError == nil { + err := ValidateTimezoneAvailability() + if err != nil { + t.Errorf("ValidateTimezoneAvailability() with no location error should return nil, got %v", err) + } + } +}