From a01d82fb41299ddb45fdab7b0b52e8a226b89b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Mon, 14 Apr 2025 12:59:43 +0200 Subject: [PATCH 01/16] feat: add xtime.FormatDuration and xtime.ParseDuration --- xtime/clean_test.go | 54 +++++++ xtime/const.go | 5 + xtime/doc.go | 110 +++++++++++++ xtime/duration.go | 349 +++++++++++++++++++++++++++++++++++++++++ xtime/duration_test.go | 317 +++++++++++++++++++++++++++++++++++++ xtime/parse.go | 240 ++++++++++++++++++++++++++++ xtime/parse_test.go | 95 +++++++++++ xtime/tokenize_test.go | 77 +++++++++ 8 files changed, 1247 insertions(+) create mode 100644 xtime/clean_test.go create mode 100644 xtime/const.go create mode 100644 xtime/doc.go create mode 100644 xtime/duration.go create mode 100644 xtime/duration_test.go create mode 100644 xtime/parse.go create mode 100644 xtime/parse_test.go create mode 100644 xtime/tokenize_test.go diff --git a/xtime/clean_test.go b/xtime/clean_test.go new file mode 100644 index 0000000..617bd30 --- /dev/null +++ b/xtime/clean_test.go @@ -0,0 +1,54 @@ +package xtime + +import ( + "testing" + + "github.com/neticdk/go-stdlib/assert" +) + +func TestClean(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + { + input: "1 hour 30 minutes", + expected: "1 hour 30 minutes", + }, + { + input: "1h30m", + expected: "1h30m", + }, + { + input: "1.5 seconds", + expected: "1.5 seconds", + }, + { + input: "1 year, 2 hour, and 5s", + expected: "1 year 2 hour 5s", + }, + { + input: ",1 years", + expected: " 1 years", + }, + { + input: "and 1 minute", + expected: "1 minute", + }, + { + input: ", 4 minutes", + expected: " 4 minutes", + }, + { + input: "", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + actual := clean(tc.input) + assert.Equal(t, actual, tc.expected) + }) + } +} diff --git a/xtime/const.go b/xtime/const.go new file mode 100644 index 0000000..f76c3d0 --- /dev/null +++ b/xtime/const.go @@ -0,0 +1,5 @@ +package xtime + +const ( + float64Size = 64 +) diff --git a/xtime/doc.go b/xtime/doc.go new file mode 100644 index 0000000..2e72b17 --- /dev/null +++ b/xtime/doc.go @@ -0,0 +1,110 @@ +// Package xtime provides functionality for working with time. +// +// # Duration Formatting (FormatDuration) +// +// The FormatDuration function converts a time.Duration into a string composed +// of multiple time units (e.g., "1h 5m 30s", "2 days, 3 hours"). This offers +// more detailed breakdowns than the standard time.Duration.String() method, +// which typically scales to the largest appropriate single unit (e.g., +// "1m30.5s"). +// +// duration := 2*xtime.Day + 3*time.Hour + 15*time.Minute + 30*time.Second + +// 500*time.Millisecond +// +// Default formatting (short style, max unit Day, min unit Second, no +// rounding): +// +// fmt.Println(xtime.FormatDuration(duration)) // Output: 2d 3h 15m 30s +// +// ## Custom formatting examples +// +// Long style, max 2 components, rounding enabled: +// +// fmt.Println(xtime.FormatDuration(duration, +// xtime.WithStyle(xtime.FormatStyleLong), +// xtime.WithMaxComponents(2), +// xtime.WithRounding(), +// )) // Output: 2 days, 3 hours +// +// Compact style: +// +// fmt.Println(xtime.FormatDuration(duration, +// xtime.WithStyle(xtime.FormatStyleCompact), +// )) // Output: 2d3h +// +// Short style, rounding enabled, only seconds and smaller displayed: +// +// fmt.Println(xtime.FormatDuration(time.Second+600*time.Millisecond, +// xtime.WithRounding(), +// xtime.WithMinUnit(time.Millisecond), +// )) // Output: 2s (Original: 1s 600ms, rounded up) +// +// Formatting Options (Functional Options Pattern): +// - WithMaxUnit(unit time.Duration): Sets the largest unit for decomposition +// (default: Day). +// - WithMinUnit(unit time.Duration): Sets the smallest unit to display +// (default: Second). Remainder is truncated or rounded. +// - WithRounding(): Enables rounding of the MinUnit based on the remainder. +// The duration is adjusted by adding half of MinUnit before decomposition. +// - WithoutRounding(): Disables rounding (default). Remainder is truncated. +// - WithMaxComponents(n int): Limits output to at most 'n' components +// (default: 0 = unlimited). +// - WithStyle(style FormatStyle): Sets output style (short, long, long-and). +// - WithSeparator(sep string): Custom separator between components (default +// depends on style). +// - WithConjunction(conj string): Custom conjunction (" and " by default) +// used before the last component in "long-and" style. +// +// # Duration Parsing (ParseDuration) +// +// The ParseDuration function converts a human-readable string representation +// into a time.Duration value. It accepts various formats, including combined +// units and common abbreviations. +// +// d1, err := xtime.ParseDuration("1h 30m 15s") +// d2, err := xtime.ParseDuration("1.5hours 10sec") // Combined number/unit and spaces work +// d3, err := xtime.ParseDuration("10 years, 2 months, 5 days") // Approximate units allowed +// d4, err := xtime.ParseDuration("3d12h") +// +// Input String Processing: +// 1. Cleaning: Leading/trailing whitespace is trimmed, "and " sequences are +// removed, and commas are replaced with spaces. +// 2. Tokenization: The cleaned string is split by spaces. Tokens containing +// both numbers and letters (e.g., "10years", "1h30m", "h1") are further +// split into number and unit parts (e.g., "10", "years", "1", "h", "30", +// "m", "h", "1"). +// 3. Parsing: +// - If only one token results and it's a valid number, it's interpreted as +// seconds. +// - Otherwise, tokens are processed in pairs (value, unit). The value must +// be a number (integer or float), and the unit must be one of the +// recognized unit strings (e.g., "h", "hour", "hours", "d", "day", "days", +// "mo", "month", "y", "year"). +// - Parsing fails if the token sequence is invalid (e.g., odd number of +// tokens, non-number where value is expected, unknown unit). +// +// Error Handling: +// - Returns a specific error type (*DurationParseError) containing details +// about the failure, including the original input, problematic token, and index. +// +// # Units and Approximations +// +// The package defines standard fixed-duration units (Week, Day) and also +// provides approximate average durations for Month (MonthApprox) and Year +// (YearApprox) based on the Gregorian calendar average (365.2425 days/year). +// +// The YearsFromDuration(d time.Duration) function converts a duration to an +// approximate number of years using YearApprox. Useful for rough estimations +// only. +// +// Note on Units Discrepancy: ParseDuration can parse approximate units like +// "month" (mo) and "year" (y) based on the average durations (MonthApprox, +// YearApprox). However, FormatDuration does not format durations using these +// approximate units; it will decompose them into weeks, days, etc., for more +// precise representation based on the fixed time.Duration value. +// +// Note: For calendar-accurate calculations involving months and years (which +// vary in length), always operate on time.Time values using functions like +// time.AddDate and time.Sub, rather than relying solely on time.Duration +// arithmetic. +package xtime diff --git a/xtime/duration.go b/xtime/duration.go new file mode 100644 index 0000000..2cbff1a --- /dev/null +++ b/xtime/duration.go @@ -0,0 +1,349 @@ +package xtime + +import ( + "sort" + "strconv" + "strings" + "time" +) + +const ( + Day time.Duration = 24 * time.Hour + Week time.Duration = 7 * Day + + // YearApprox is the average duration of a year in the Gregorian calendar (365.2425 days). + // Use this for approximations only; it does not account for calendar specifics. + YearApprox time.Duration = time.Duration(365.2425 * float64(Day)) + + // MonthApprox is the average duration of a month in the Gregorian calendar (30.436875 days). + // Use this for approximations only; it does not account for calendar specifics. + MonthApprox time.Duration = time.Duration(float64(YearApprox) / 12) +) + +// timeUnitDef holds the definition for a single time unit used in formatting. +type timeUnitDef struct { + // Unit is the duration value corresponding to one of this unit (e.g., xtime.Hour). + Unit time.Duration + // NameSingular is the full singular name (e.g., "hour"). + NameSingular string + // NamePlural is the full plural name (e.g., "hours"). + NamePlural string + // Symbol is the short symbol (e.g., "h"). + Symbol string +} + +var definedUnits = []timeUnitDef{ + {Week, "week", "weeks", "w"}, + {Day, "day", "days", "d"}, + {time.Hour, "hour", "hours", "h"}, + {time.Minute, "minute", "minutes", "m"}, + {time.Second, "second", "seconds", "s"}, + {time.Millisecond, "millisecond", "milliseconds", "ms"}, + {time.Microsecond, "microsecond", "microseconds", "µs"}, + {time.Nanosecond, "nanosecond", "nanoseconds", "ns"}, +} + +// FormatStyle defines the output style for formatted durations. +type FormatStyle string + +const ( + // FormatStyleCompact uses abbreviated units without spaces ("1h5m30s"). + FormatStyleCompact FormatStyle = "compact" + // FormatStyleShort uses abbreviated units ("1h 5m 30s"). + FormatStyleShort FormatStyle = "short" + // FormatStyleLong uses full unit names ("1 hour, 5 minutes, 30 seconds"). + FormatStyleLong FormatStyle = "long" + // FormatStyleLongAnd uses full names with "and" before the last component + // ("1 hour, 5 minutes and 30 seconds"). + FormatStyleLongAnd FormatStyle = "long-and" +) + +// FormatOptions provides configuration for Format. +type FormatOptions struct { + // MaxUnit is the largest time unit to display (e.g., HourNs, DayNs). + // Components larger than this will be represented in terms of this unit. + // Default: DayNs. + MaxUnit time.Duration + // MinUnit is the smallest time unit to display (e.g., SecondNs, MillisecondNs). + // Any remaining duration smaller than this will be truncated or rounded depending on RoundMinUnit. + // Default: SecondNs. + MinUnit time.Duration + // Rounding enables rounding of the smallest displayed unit based on the remainder. + // If false (default), the remainder is truncated. + Rounding bool + // MaxComponents limits the maximum number of components displayed (e.g., 2 // might yield "1h 5m"). + // Set to 0 or negative for unlimited components (down to MinUnit). + // Default: 0 (unlimited). + MaxComponents int + // Style determines the format of unit names (short, long, long-and). + // Default: FormatStyleShort. + Style FormatStyle + // Separator is the string used between components (ignored if only one component). + // Default: ", " for long styles, " " for short style. + Separator string + // Conjunction is the string used before the last component in "long-and" style. + // Default: " and ". + Conjunction string +} + +// DefaultFormatOptions creates options with default values. +func DefaultFormatOptions() FormatOptions { + return FormatOptions{ + MaxUnit: Day, + MinUnit: time.Second, + Rounding: false, + MaxComponents: 0, // Unlimited + Style: FormatStyleShort, + Separator: " ", // Default for short + Conjunction: " and ", + } +} + +// FormatOption is a function type for setting format options. +type FormatOption func(*FormatOptions) + +// WithMaxUnit sets the largest unit to display. +func WithMaxUnit(unit time.Duration) FormatOption { + return func(o *FormatOptions) { + if unit > 0 { + o.MaxUnit = unit + } + } +} + +// WithMinUnit sets the smallest unit to display. +func WithMinUnit(unit time.Duration) FormatOption { + return func(o *FormatOptions) { + if unit >= time.Nanosecond { + o.MinUnit = unit + } + } +} + +// WithRounding enables rounding of the MinUnit. +func WithRounding() FormatOption { + return func(o *FormatOptions) { + o.Rounding = true + } +} + +// WithoutRounding disables rounding of the MinUnit. +func WithoutRounding() FormatOption { + return func(o *FormatOptions) { + o.Rounding = false + } +} + +// WithMaxComponents sets the max number of components. 0 means unlimited. +func WithMaxComponents(p int) FormatOption { + return func(o *FormatOptions) { + o.MaxComponents = p + } +} + +// WithStyle sets the output style (short, long, long-and). +func WithStyle(style FormatStyle) FormatOption { + return func(o *FormatOptions) { + o.Style = style + // Adjust default separator based on style if not explicitly set later + switch style { + case FormatStyleShort: + o.Separator = " " + case FormatStyleCompact: + o.Separator = "" + default: + o.Separator = ", " + } + } +} + +// WithSeparator sets the separator string. +func WithSeparator(sep string) FormatOption { + return func(o *FormatOptions) { + o.Separator = sep + } +} + +// WithConjunction sets the conjunction string for "long-and" style. +func WithConjunction(conj string) FormatOption { + return func(o *FormatOptions) { + o.Conjunction = conj + } +} + +// componentResult holds the calculated quantity and unit definition for one part. +type componentResult struct { + Quantity int64 + UnitDef timeUnitDef +} + +// FormatDuration formats a duration into a human-readable string +// composed of multiple time units (e.g., "1h 5m 30s", "2 days, 3 hours"). +// +// Use FormatOptions functions (WithMaxUnit, WithMinUnit, WithRounding, +// WithMaxComponents, WithStyle, WithSeparator, WithConjunction) to customize output. +// +// Example: +// +// FormatDuration(90*time.Minute, WithStyle(FormatStyleLong)) // "1 hour, 30 minutes" +// FormatDuration(3723*time.Second, WithMinUnit(time.Second), WithMaxComponents(2)) // "1h 2m" +// FormatDuration(time.Second + 600*time.Millisecond, WithRounding()) // "2s" +func FormatDuration(d time.Duration, opts ...FormatOption) string { + options := DefaultFormatOptions() + for _, opt := range opts { + opt(&options) + } + + if d == 0 { + minUnitDef, foundMin := findUnitDef(options.MinUnit) + if !foundMin { + minUnitDef, _ = findUnitDef(time.Second) + } + return formatComponent(0, minUnitDef, options.Style) + } + + isNegative := d < 0 + if isNegative { + d = -d + } + + // Simple rounding: Add half of the minimum unit before processing + if options.Rounding && options.MinUnit > 0 { + d += options.MinUnit / 2 + } + + // Calculate components + results := []componentResult{} + remaining := d + + minUnitDef, foundMin := findUnitDef(options.MinUnit) + if !foundMin { + minUnitDef, _ = findUnitDef(time.Second) // Fallback + } + + for _, unitDef := range definedUnits { + if unitDef.Unit > options.MaxUnit { + continue + } + // Stop if we hit the component limit *before* processing this unit + if options.MaxComponents > 0 && len(results) >= options.MaxComponents { + break + } + if unitDef.Unit < options.MinUnit { + break + } + + if remaining >= unitDef.Unit { + quantity := remaining / unitDef.Unit + // Note: 'remaining' here still holds the full remainder including + // sub-MinUnit parts because we modified 'd' upfront. + currentUnitRemainder := remaining % unitDef.Unit + + if quantity > 0 { + results = append(results, componentResult{ + Quantity: int64(quantity), + UnitDef: unitDef, + }) + // Update remaining for the *next* iteration + remaining = currentUnitRemainder + } + } + } + + // Handle edge case where rounding resulted in zero components, but + // original > 0 or where the original duration was less than MinUnit but + // rounded up. + if len(results) == 0 { + // If the (potentially rounded) duration is >= MinUnit, display 1 MinUnit + if d >= options.MinUnit { + return formatComponent(1, minUnitDef, options.Style) + } + // Otherwise, it was < MinUnit and didn't round up, display 0 MinUnit + return formatComponent(0, minUnitDef, options.Style) + } + + // Format components + components := make([]string, 0, len(results)) + for _, res := range results { + components = append( + components, + formatComponent(res.Quantity, res.UnitDef, options.Style)) + } + + // Join components + result := joinComponents( + components, + options.Style, + options.Separator, + options.Conjunction) + + if isNegative { + return "-" + result + } + + return result +} + +// formatComponent formats a single quantity and unit definition based on style. +func formatComponent(quantity int64, unitDef timeUnitDef, style FormatStyle) string { + s := strconv.FormatInt(quantity, 10) + + switch style { + case FormatStyleLong, FormatStyleLongAnd: + unitName := unitDef.NameSingular + if quantity != 1 { + unitName = unitDef.NamePlural + } + return s + " " + unitName + default: // FormatStyleCompact, FormatStyleShort + return s + unitDef.Symbol + } +} + +// joinComponents joins the formatted string components based on style and separators. +func joinComponents(components []string, style FormatStyle, separator, conjunction string) string { + count := len(components) + if count == 0 { + return "" + } + if count == 1 { + return components[0] + } + + if style == FormatStyleLongAnd && count > 1 { + allButLast := strings.Join(components[:count-1], separator) + return allButLast + conjunction + components[count-1] + } + + return strings.Join(components, separator) +} + +// findUnitDef searches definedUnits for a specific duration value. +func findUnitDef(unit time.Duration) (timeUnitDef, bool) { + for _, def := range definedUnits { + if def.Unit == unit { + return def, true + } + } + return timeUnitDef{}, false +} + +// YearsFromDuration converts a duration into an approximate number of years. +// It calculates this based on the average length of a year in the +// Gregorian calendar (365.2425 days). +// +// WARNING: This function provides an estimation based on duration only. +// It does not account for specific calendar start/end dates, leap year +// occurrences within a specific period, or time zones. For calendar-accurate +// differences involving years and months, use functions operating on time.Time +// values. +func YearsFromDuration(d time.Duration) float64 { + return float64(d) / float64(YearApprox) +} + +// init ensures definedUnits is sorted correctly on package load +func init() { + sort.SliceStable(definedUnits, func(i, j int) bool { + return definedUnits[i].Unit > definedUnits[j].Unit + }) +} diff --git a/xtime/duration_test.go b/xtime/duration_test.go new file mode 100644 index 0000000..281e925 --- /dev/null +++ b/xtime/duration_test.go @@ -0,0 +1,317 @@ +package xtime_test + +import ( + "testing" + "time" + + "github.com/neticdk/go-stdlib/assert" + "github.com/neticdk/go-stdlib/xtime" +) + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + options []xtime.FormatOption + expected string + }{ + // Basic Tests (Default Options: Max=Day, Min=Sec, Style=Short, No Rounding) + { + name: "zero", + duration: 0, + options: nil, + expected: "0s", + }, + { + name: "simple seconds", + duration: 45 * time.Second, + options: nil, + expected: "45s", + }, + { + name: "simple minutes seconds", + duration: 90 * time.Second, + options: nil, + expected: "1m 30s", + }, + { + name: "simple hours minutes seconds", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: nil, + expected: "1h 2m 3s", + }, + { + name: "simple days hours", + duration: 2*xtime.Day + 5*time.Hour, + options: nil, + expected: "2d 5h", + }, + { + name: "simple weeks days (requires WithMaxUnit)", + duration: 2*xtime.Week + 3*xtime.Day + 1*time.Hour, + options: []xtime.FormatOption{xtime.WithMaxUnit(xtime.Week)}, + expected: "2w 3d 1h", + }, + { + name: "negative duration", + duration: -(90 * time.Second), + options: nil, + expected: "-1m 30s", + }, + { + name: "sub-second default truncation", + duration: 1*time.Second + 600*time.Millisecond, + options: nil, + expected: "1s", // 600ms truncated + }, + + // Style Tests + { + name: "compact style", + duration: 90 * time.Second, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleCompact)}, + expected: "1m30s", + }, + { + name: "compact style single component", + duration: 2 * time.Hour, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleCompact)}, + expected: "2h", + }, + { + name: "long style", + duration: 90 * time.Second, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleLong)}, + expected: "1 minute, 30 seconds", + }, + { + name: "long style single component (plural)", + duration: 2 * time.Hour, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleLong)}, + expected: "2 hours", + }, + { + name: "long style single component (singular)", + duration: 1 * time.Minute, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleLong)}, + expected: "1 minute", + }, + { + name: "long-and style", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleLongAnd)}, + expected: "1 hour, 2 minutes and 3 seconds", + }, + { + name: "long-and style two components", + duration: 90 * time.Second, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleLongAnd)}, + expected: "1 minute and 30 seconds", + }, + + // MaxComponents Tests + { + name: "max components 1", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: []xtime.FormatOption{xtime.WithMaxComponents(1)}, + expected: "1h", + }, + { + name: "max components 2", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: []xtime.FormatOption{xtime.WithMaxComponents(2)}, + expected: "1h 2m", + }, + { + name: "max components 3", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: []xtime.FormatOption{xtime.WithMaxComponents(3)}, + expected: "1h 2m 3s", + }, + { + name: "max components unlimited (0)", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: []xtime.FormatOption{xtime.WithMaxComponents(0)}, + expected: "1h 2m 3s", + }, + { + name: "max components cuts off before min unit", + duration: 1*time.Minute + 30*time.Second + 500*time.Millisecond, + options: []xtime.FormatOption{xtime.WithMaxComponents(1), xtime.WithMinUnit(time.Millisecond)}, + expected: "1m", + }, + + // Min/Max Unit Tests + { + name: "min unit minutes", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: []xtime.FormatOption{xtime.WithMinUnit(time.Minute)}, + expected: "1h 2m", // 3s is truncated + }, + { + name: "max unit minutes", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, // 62m 3s + options: []xtime.FormatOption{xtime.WithMaxUnit(time.Minute)}, + expected: "62m 3s", // 1h becomes 60m + }, + { + name: "min unit ms", + duration: 1*time.Second + 500*time.Millisecond + 500*time.Microsecond + 100*time.Nanosecond, + options: []xtime.FormatOption{xtime.WithMinUnit(time.Millisecond)}, + expected: "1s 500ms", // µs and ns truncated + }, + { + name: "min unit ns", + duration: 1*time.Second + 5*time.Nanosecond, + options: []xtime.FormatOption{xtime.WithMinUnit(time.Nanosecond)}, + expected: "1s 5ns", + }, + { + name: "duration less than min unit (default sec)", + duration: 500 * time.Millisecond, + options: nil, + expected: "0s", // Duration is non-zero but less than MinUnit, show 0 of min unit + }, + { + name: "duration less than min unit (explicit ms)", + duration: 500 * time.Microsecond, + options: []xtime.FormatOption{xtime.WithMinUnit(time.Millisecond)}, + expected: "0ms", + }, + + // Rounding Tests + { + name: "rounding disabled (default)", + duration: 1*time.Second + 600*time.Millisecond, + options: nil, // MinUnit=Second + expected: "1s", + }, + { + name: "rounding enabled, rounds up", + duration: 1*time.Second + 600*time.Millisecond, + options: []xtime.FormatOption{xtime.WithRounding()}, // MinUnit=Second + expected: "2s", + }, + { + name: "rounding enabled, rounds down", + duration: 1*time.Second + 400*time.Millisecond, + options: []xtime.FormatOption{xtime.WithRounding()}, // MinUnit=Second + expected: "1s", + }, + { + name: "rounding enabled, exactly half", + duration: 1*time.Second + 500*time.Millisecond, + options: []xtime.FormatOption{xtime.WithRounding()}, // MinUnit=Second + expected: "2s", // Ties round up + }, + { + name: "rounding with min unit ms", + duration: 1*time.Millisecond + 600*time.Microsecond, + options: []xtime.FormatOption{xtime.WithMinUnit(time.Millisecond), xtime.WithRounding()}, + expected: "2ms", + }, + { + name: "rounding with carry-over seconds to minutes", + duration: 59*time.Second + 700*time.Millisecond, + options: []xtime.FormatOption{xtime.WithRounding()}, // MinUnit=Second + expected: "1m", + }, + { + name: "rounding with carry-over minutes to hours", + duration: 59*time.Minute + 45*time.Second, // rounds to 60m + options: []xtime.FormatOption{xtime.WithRounding(), xtime.WithMinUnit(time.Minute)}, + expected: "1h", + }, + { + name: "rounding with carry-over multiple levels", + duration: 1*time.Hour + 59*time.Minute + 59*time.Second + 800*time.Millisecond, + options: []xtime.FormatOption{xtime.WithRounding()}, // MinUnit=Second, rounds to 1h 60m 0s -> 2h + expected: "2h", + }, + { + name: "rounding with max components", + duration: 1*time.Hour + 59*time.Minute + 30*time.Second, // rounds to 1h 60m -> 2h + options: []xtime.FormatOption{xtime.WithRounding(), xtime.WithMinUnit(time.Minute), xtime.WithMaxComponents(1)}, + expected: "2h", // Rounds up and only shows the first component + }, + { + name: "rounding zero component", + duration: 1*time.Hour + 30*time.Second, // Add 1h 0m 30s + options: []xtime.FormatOption{xtime.WithRounding()}, + expected: "1h 30s", + }, + + // Separator/Conjunction Tests + { + name: "custom separator", + duration: 90 * time.Second, + options: []xtime.FormatOption{xtime.WithSeparator(":")}, + expected: "1m:30s", + }, + { + name: "custom conjunction", + duration: 90 * time.Second, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleLongAnd), xtime.WithConjunction(" plus ")}, + expected: "1 minute plus 30 seconds", + }, + { + name: "long style custom separator", + duration: 1*time.Hour + 2*time.Minute + 3*time.Second, + options: []xtime.FormatOption{xtime.WithStyle(xtime.FormatStyleLong), xtime.WithSeparator(" - ")}, + expected: "1 hour - 2 minutes - 3 seconds", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Call the function under test + got := xtime.FormatDuration(tt.duration, tt.options...) + + // Assert equality using the assert package + assert.Equal(t, got, tt.expected) + }) + } +} + +func TestYearsFromDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected float64 + }{ + {"zero", 0, 0.0}, + {"one day", xtime.Day, 1.0 / 365.2425}, + {"365 days", 365 * xtime.Day, 365.0 / 365.2425}, + {"one approx year", xtime.YearApprox, 1.0}, + {"two approx years", 2 * xtime.YearApprox, 2.0}, + {"negative approx year", -1 * xtime.YearApprox, -1.0}, + } + + // Define a small delta for float comparison + delta := 1e-9 + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := xtime.YearsFromDuration(tt.duration) + assert.InDelta(t, got, tt.expected, delta) + }) + } +} + +// Test helper to ensure rounding carry-over rebuilds components correctly +// (Needed special test case because the main table rebuild logic had a subtle bug) +func TestFormat_RoundingCarryOverRebuild(t *testing.T) { + // 59 seconds + 700 ms -> rounds up to 60 seconds -> carries over to 1 minute + duration := 59*time.Second + 700*time.Millisecond + opts := []xtime.FormatOption{xtime.WithRounding(), xtime.WithMinUnit(time.Second)} + expected := "1m" // Should not be "1m 0s" + got := xtime.FormatDuration(duration, opts...) + assert.Equal(t, got, expected, "Rounding 59.7s to 1m") + + // 59m 59s + 700ms -> rounds to 59m 60s -> carries to 60m 0s -> carries to 1h 0m 0s + duration = 59*time.Minute + 59*time.Second + 700*time.Millisecond + opts = []xtime.FormatOption{xtime.WithRounding(), xtime.WithMinUnit(time.Second)} + expected = "1h" // Should not be "1h 0m 0s" + got = xtime.FormatDuration(duration, opts...) + assert.Equal(t, got, expected, "Rounding 59m59.7s to 1h") +} diff --git a/xtime/parse.go b/xtime/parse.go new file mode 100644 index 0000000..0b3816d --- /dev/null +++ b/xtime/parse.go @@ -0,0 +1,240 @@ +package xtime + +import ( + "fmt" + "strconv" + "strings" + "time" + "unicode" +) + +// DurationParseError provides specific details about a duration parsing failure. +type DurationParseError struct { + // The original input string that caused the error. + Input string + // The specific token that caused the error, if applicable. + Token string + // The index of the problematic token, if applicable. + TokenIndex int + // Description of the error. + Message string +} + +func (e *DurationParseError) Error() string { + if e.TokenIndex >= 0 && e.Token != "" { + return fmt.Sprintf("xtime: %s (token='%s' at index %d in input='%s')", e.Message, e.Token, e.TokenIndex, e.Input) + } + return fmt.Sprintf("xtime: %s (input='%s')", e.Message, e.Input) +} + +func newParseError(input, message string) error { + return &DurationParseError{Input: input, Message: message} +} + +func newParseTokenError(input, token, message string, index int) error { + return &DurationParseError{Input: input, Token: token, TokenIndex: index, Message: message} +} + +// ParseDuration converts a human-readable duration string into +// a time.Duration. +// +// It first cleans the input string using the clean() function (removing extra +// words like "and", replacing commas with spaces, trimming whitespace). +// Then, it tokenizes the cleaned string using the tokenize() function, which +// splits the string by spaces and also by transitions between numbers and +// letters (e.g., "1h30m" becomes ["1", "h", "30", "m"]). +// +// The function handles the following cases: +// - Empty or whitespace-only input: Returns an error. +// - Single token input: Attempts to parse it as a float64 representing +// seconds. Returns an error if the token is not a valid number. +// - Multiple tokens: Expects an even number of tokens representing pairs of +// (value, unit). It iterates through these pairs, parses the value as a +// float64, looks up the unit in the predefined units map, and accumulates +// the total duration. +// +// It returns an error if: +// - The input is empty after cleaning. +// - A single token cannot be parsed as a number. +// - There is an odd number of tokens (expecting pairs). +// - A token expected to be a value cannot be parsed as a float64. +// - A token expected to be a unit is not found in the units map. +func ParseDuration(s string) (time.Duration, error) { + return parse(s) +} + +var units = map[string]time.Duration{ + "ns": time.Nanosecond, + "nanosecond": time.Nanosecond, + "nanoseconds": time.Nanosecond, + "μs": time.Microsecond, + "us": time.Microsecond, + "microsecond": time.Microsecond, + "microseconds": time.Microsecond, + "ms": time.Millisecond, + "millisecond": time.Millisecond, + "milliseconds": time.Millisecond, + "s": time.Second, + "sec": time.Second, + "secs": time.Second, + "second": time.Second, + "seconds": time.Second, + "m": time.Minute, + "min": time.Minute, + "mins": time.Minute, + "minute": time.Minute, + "minutes": time.Minute, + "h": time.Hour, + "hr": time.Hour, + "hour": time.Hour, + "hours": time.Hour, + "d": 24 * time.Hour, + "day": 24 * time.Hour, + "days": 24 * time.Hour, + "w": 7 * 24 * time.Hour, + "week": 7 * 24 * time.Hour, + "weeks": 7 * 24 * time.Hour, + "mo": MonthApprox, // Approximate + "month": MonthApprox, // Approximate + "months": MonthApprox, // Approximate + "y": YearApprox, // Approximate + "year": YearApprox, // Approximate + "years": YearApprox, // Approximate +} + +func parse(input string) (time.Duration, error) { + cleanedInput := clean(input) + tokens := tokenize(cleanedInput) + + var duration time.Duration + + if len(tokens) == 0 { + return 0, newParseError(input, "string resulted in zero tokens after cleaning") + } + + // Handle single number input (interpreted as seconds) + if len(tokens) == 1 { + token := tokens[0] + seconds, err := strconv.ParseFloat(token, float64Size) + if err == nil { + duration = time.Duration(seconds * float64(time.Second)) + return duration, nil + } + msg := "single token is not a valid number (expected seconds)" + return 0, newParseTokenError(input, token, msg, 0) + + } + + // Expect pairs of (value, unit) from here on + if len(tokens)%2 != 0 { + msg := fmt.Sprintf("expected pairs of value and unit, but got %d tokens", len(tokens)) + return 0, newParseError(input, msg) + } + + for i := 0; i < len(tokens); i += 2 { + valueStr := tokens[i] + unitStr := tokens[i+1] + + value, err := strconv.ParseFloat(valueStr, float64Size) + if err != nil { + msg := "expected a number" + return 0, newParseTokenError(input, valueStr, msg, i) + } + + unitMultiplier, ok := units[unitStr] + if !ok { + msg := "unknown unit" + return 0, newParseTokenError(input, unitStr, msg, i+1) + } + + duration += time.Duration(value * float64(unitMultiplier)) + } + + return duration, nil +} + +// clean cleans the input string +func clean(input string) string { + s := strings.TrimSpace(input) + s = strings.ReplaceAll(s, "and ", "") + s = strings.ReplaceAll(s, ",", " ") + + return s +} + +const ( + typeUnknown = iota + typeDigitOrDecimal + typeLetter +) + +// tokenize splits a cleaned duration string into potential number and unit +// tokens. +// +// It operates in two stages: +// 1. Splits the input string by whitespace into fields. +// 2. Processes each field: +// - If a field starts with '.', a '0' is prepended (e.g., ".5h" -> "0.5h"). +// - The field is then split internally whenever the character type changes +// between a digit/decimal point and a letter. For example: +// "10years" -> ["10", "years"] +// "1h30m" -> ["1", "h", "30", "m"] +// "h1" -> ["h", "1"] +// +// This function does not perform validation on the content or sequence of +// tokens (e.g., it doesn't check if "1.2.3" is a valid number or if "xyz" is +// a known unit, or if tokens are in number-unit pairs). It aims to never +// return an error, deferring all validation to the caller (typically the parse +// function). Input is assumed to be already cleaned (e.g., no commas, extra +// words). +func tokenize(cleanedInput string) []string { + fields := strings.Fields(cleanedInput) + finalTokens := make([]string, 0, len(fields)) + + for _, field := range fields { + var currentToken strings.Builder + lastType := typeUnknown + + // Handle leading decimal for the whole field ".5h" -> "0.5h" + // Or ".h" -> "0.h" + processedField := field + if field[0] == '.' { + processedField = "0" + field + } + + for i, r := range processedField { + currentType := typeUnknown + if unicode.IsDigit(r) || r == '.' { + currentType = typeDigitOrDecimal + } else if unicode.IsLetter(r) { + currentType = typeLetter + } + + // Start of the first token in the field + if i == 0 { + currentToken.WriteRune(r) + lastType = currentType + continue + } + + // If type changes between Letter and Digit/Decimal, split + if (lastType == typeLetter && currentType == typeDigitOrDecimal) || + (lastType == typeDigitOrDecimal && currentType == typeLetter) { + // Add the completed token + finalTokens = append(finalTokens, currentToken.String()) + // Start the new token + currentToken.Reset() + } + + currentToken.WriteRune(r) + lastType = currentType + } + + // Add the last token from the field + if currentToken.Len() > 0 { + finalTokens = append(finalTokens, currentToken.String()) + } + } + + return finalTokens +} diff --git a/xtime/parse_test.go b/xtime/parse_test.go new file mode 100644 index 0000000..42e90e8 --- /dev/null +++ b/xtime/parse_test.go @@ -0,0 +1,95 @@ +package xtime_test + +import ( + "testing" + "time" + + "github.com/neticdk/go-stdlib/assert" + "github.com/neticdk/go-stdlib/xtime" +) + +func TestParseDuration(t *testing.T) { + testCases := []struct { + name string + input string + want time.Duration + wantErr bool + }{ + // --- Valid Cases --- + {"single number seconds int", "10", 10 * time.Second, false}, + {"single number seconds float", "15.5", time.Duration(15.5 * float64(time.Second)), false}, + {"single number zero", "0", 0, false}, + {"simple hour", "1h", time.Hour, false}, + {"simple minute", "5m", 5 * time.Minute, false}, + {"simple second", "30s", 30 * time.Second, false}, + {"simple day", "2d", 2 * xtime.Day, false}, + {"simple week", "3w", 3 * xtime.Week, false}, + {"simple millisecond", "500ms", 500 * time.Millisecond, false}, + {"simple microsecond", "250us", 250 * time.Microsecond, false}, + {"simple microsecond mu", "250μs", 250 * time.Microsecond, false}, + {"simple nanosecond", "100ns", 100 * time.Nanosecond, false}, + {"float value", "1.5h", time.Duration(1.5 * float64(time.Hour)), false}, + {"combined no space", "1h30m", time.Hour + 30*time.Minute, false}, + {"combined with space", "1h 30m", time.Hour + 30*time.Minute, false}, + {"multiple components", "1h 30m 15s", time.Hour + 30*time.Minute + 15*time.Second, false}, + {"multiple components no spaces", "1h30m15s", time.Hour + 30*time.Minute + 15*time.Second, false}, + {"mixed units", "1d 12h 30m 5s", xtime.Day + 12*time.Hour + 30*time.Minute + 5*time.Second, false}, + {"abbreviations sec", "10sec", 10 * time.Second, false}, + {"abbreviations secs", "15secs", 15 * time.Second, false}, + {"abbreviations min", "5min", 5 * time.Minute, false}, + {"abbreviations mins", "2mins", 2 * time.Minute, false}, + {"abbreviations hr", "3hr", 3 * time.Hour, false}, + {"long names singular", "1 day 2 hour 3 minute 4 second", xtime.Day + 2*time.Hour + 3*time.Minute + 4*time.Second, false}, + {"long names plural", "2 days 3 hours 4 minutes 5 seconds", 2*xtime.Day + 3*time.Hour + 4*time.Minute + 5*time.Second, false}, + {"leading space", " 1h", time.Hour, false}, + {"trailing space", "1h ", time.Hour, false}, + {"multiple spaces", "1h 30m", time.Hour + 30*time.Minute, false}, + {"comma separator", "1h,30m", time.Hour + 30*time.Minute, false}, + {"comma separator with space", "1 day, 12 hours", xtime.Day + 12*time.Hour, false}, + {"'and ' separator", "1h and 30m", time.Hour + 30*time.Minute, false}, // Relies on clean removing "and " + {"approximate month", "1mo", xtime.MonthApprox, false}, + {"approximate year", "2y", 2 * xtime.YearApprox, false}, + {"mixed approx and fixed", "1y 6mo 2w 3d", xtime.YearApprox + 6*xtime.MonthApprox + 2*xtime.Week + 3*xtime.Day, false}, + {"leading decimal use", ".h", time.Duration(0 * float64(time.Second)), false}, + {"leading decimal number", ".5s", time.Duration(0.5 * float64(time.Second)), false}, + {"leading decimal combined", ".5h30m", time.Duration(0.5*float64(time.Hour)) + 30*time.Minute, false}, + {"trailing decimal number", "1.s", time.Second, false}, // strconv.ParseFloat handles "1." as 1.0 + {"trailing decimal combined", "1.h30m", time.Hour + 30*time.Minute, false}, + {"zero value units", "0h 0m 0s", 0, false}, + {"large value", "1000d", 1000 * xtime.Day, false}, + + // --- Invalid Cases (Expect Error) --- + {"empty string", "", 0, true}, + {"whitespace only", " ", 0, true}, + {"just comma", ",", 0, true}, + {"just and", "and ", 0, true}, + {"single non-number", "h", 0, true}, + {"single invalid token", "abc", 0, true}, + {"odd number of tokens", "1h 30", 0, true}, + {"non-number in value pos 1", "h 1", 0, true}, + {"non-number in value pos 2", "1h m 30s", 0, true}, + {"unknown unit", "1h 30foo", 0, true}, + {"unknown unit short", "10 quark", 0, true}, + {"invalid character embedded", "1h$30m", 0, true}, + {"multiple decimals", "1.2.3h", 0, true}, + {"unit value reversed", "h1", 0, true}, + {"value value", "1 2", 0, true}, + {"unit unit", "h m", 0, true}, + {"number touching invalid", "1h$", 0, true}, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, err := xtime.ParseDuration(tt.input) + + if tt.wantErr { + assert.Error(t, err, "ParseDuration/%s", tt.name) + var parseErr *xtime.DurationParseError + assert.ErrorAs(t, err, &parseErr, "ParseDuration/%s", tt.name) + } else { + assert.NoError(t, err, "ParseDuration/%s", tt.name) + assert.Equal(t, got, tt.want, "ParseDuration/%s", tt.name) + } + }) + } +} diff --git a/xtime/tokenize_test.go b/xtime/tokenize_test.go new file mode 100644 index 0000000..88fb135 --- /dev/null +++ b/xtime/tokenize_test.go @@ -0,0 +1,77 @@ +package xtime + +import ( + "testing" + + "github.com/neticdk/go-stdlib/assert" +) + +func TestTokenize(t *testing.T) { + testCases := []struct { + name string + input string + want []string + }{ + // Basic cases + {"empty string", "", []string{}}, + {"single space", " ", []string{}}, + {"multiple spaces", " ", []string{}}, + {"single number integer", "123", []string{"123"}}, + {"single number float", "123.45", []string{"123.45"}}, + {"single unit", "h", []string{"h"}}, + + // Simple pairs (no space) + {"pair no space int", "1h", []string{"1", "h"}}, + {"pair no space float", "1.5m", []string{"1.5", "m"}}, + {"pair no space long unit", "10years", []string{"10", "years"}}, + + // Simple pairs (with space) + {"pair with space int", "1 h", []string{"1", "h"}}, + {"pair with space float", "1.5 m", []string{"1.5", "m"}}, + {"pair with space long unit", "10 years", []string{"10", "years"}}, + + // Multiple pairs + {"multiple pairs no spaces", "1h30m15s", []string{"1", "h", "30", "m", "15", "s"}}, + {"multiple pairs with spaces", "1 h 30 m 15 s", []string{"1", "h", "30", "m", "15", "s"}}, + {"multiple pairs mixed spaces 1", "1h 30m 15s", []string{"1", "h", "30", "m", "15", "s"}}, + {"multiple pairs mixed spaces 2", " 1h30m 15s ", []string{"1", "h", "30", "m", "15", "s"}}, + {"multiple pairs floats", "1.5h 30.2m 0.5s", []string{"1.5", "h", "30.2", "m", "0.5", "s"}}, + + // Unit followed by number (should split) + {"unit then number", "h1", []string{"h", "1"}}, + {"unit then float", "m1.5", []string{"m", "1.5"}}, + {"unit then decimal start", "s.5", []string{"s", ".5"}}, + {"multiple unit then number", "h1m2s3", []string{"h", "1", "m", "2", "s", "3"}}, + + // Decimal handling + {"leading decimal number", ".5", []string{"0.5"}}, + {"leading decimal pair", ".5h", []string{"0.5", "h"}}, + {"number with trailing decimal", "1.", []string{"1."}}, + {"pair with trailing decimal", "1.h", []string{"1.", "h"}}, + {"multiple decimals", "1.2.3", []string{"1.2.3"}}, + {"multiple decimals in pair", "1.2.3h", []string{"1.2.3", "h"}}, + {"decimal only", ".", []string{"0."}}, + {"decimal only unit", ".h", []string{"0.", "h"}}, + + // Edge cases with spaces + {"trailing space", "1h ", []string{"1", "h"}}, + {"leading space", " 1h", []string{"1", "h"}}, + {"spaces around", " 1h30m ", []string{"1", "h", "30", "m"}}, + {"multiple internal spaces", "1 h 30 m", []string{"1", "h", "30", "m"}}, + + // Longer units mixed + {"long units mixed", "1hour30mins", []string{"1", "hour", "30", "mins"}}, + {"long units spaces", "1 hour 30 mins", []string{"1", "hour", "30", "mins"}}, + {"long units mixed three units", "1hour30mins10seconds", []string{"1", "hour", "30", "mins", "10", "seconds"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Note: These tests assume the input to tokenize is already "cleaned" + // (no commas, no "and "). + got := tokenize(tc.input) + + assert.Equal(t, got, tc.want, "tokenize/%s", tc.name) + }) + } +} From fc072df4c0ff78a7fccbe57114cff775a428bbd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Mon, 14 Apr 2025 13:33:13 +0200 Subject: [PATCH 02/16] fix: update README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4c4ab5c..a853f24 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ comes in the form of a collection of packages. ## Dependencies -The packages are dependency free meaning. Packages added to this module must not +The packages are dependency free meaning packages added to this module must not use any external dependencies unless listed below. Exceptions: @@ -28,20 +28,19 @@ Do *NOT* add exceptions to this list without peer review. - Prefix names for packages that mirror a go standard library package with `x`. - Prefix names for packages that are likely to mirror future go standard library - Packages with `x`. + packages with `x`. - Use singular names for package (except in the mentioned cases). ## Testing - Unit testing is mandatory. -- Go for > 95% coverage, preferably 100%. +- Go for > 90% coverage, preferably 100%. ## Documentation - Document all exported (public) identifiers - Maintain a `doc.go` in each package with introduction, installation instructions and usage examples. -- Use `make gen` to generate `README.md` files ### doc.go minimal content @@ -62,6 +61,7 @@ package mypkg - `xslices` - slice data type functions - `xstrings` - string data type functions - `xstructs` - struct data type functions +- `xtime` - time functions ## Installation From 0e9aad18d9097a50e4cdac221eddafb0a8c59fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Wed, 16 Apr 2025 12:52:44 +0200 Subject: [PATCH 03/16] docs: update documentation --- xtime/duration.go | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/xtime/duration.go b/xtime/duration.go index 2cbff1a..12a7011 100644 --- a/xtime/duration.go +++ b/xtime/duration.go @@ -60,28 +60,35 @@ const ( // FormatOptions provides configuration for Format. type FormatOptions struct { - // MaxUnit is the largest time unit to display (e.g., HourNs, DayNs). - // Components larger than this will be represented in terms of this unit. - // Default: DayNs. + // MaxUnit is the largest time unit to display (e.g., time.Hour, + // xtime.Day). Components larger than this will be represented in terms of + // this unit. + // Default: xtime.Day. MaxUnit time.Duration - // MinUnit is the smallest time unit to display (e.g., SecondNs, MillisecondNs). - // Any remaining duration smaller than this will be truncated or rounded depending on RoundMinUnit. - // Default: SecondNs. + // MinUnit is the smallest time unit to display (e.g., time.Second, + // time.Millisecond). Any remaining duration smaller than this will be + // truncated or rounded depending on Rounding. + // Default: time.Second. MinUnit time.Duration - // Rounding enables rounding of the smallest displayed unit based on the remainder. + // Rounding enables rounding of the smallest displayed unit based on the + // remainder. // If false (default), the remainder is truncated. Rounding bool - // MaxComponents limits the maximum number of components displayed (e.g., 2 // might yield "1h 5m"). + // MaxComponents limits the maximum number of components displayed (e.g., + // 2 // might yield "1h 5m"). // Set to 0 or negative for unlimited components (down to MinUnit). // Default: 0 (unlimited). MaxComponents int - // Style determines the format of unit names (short, long, long-and). + // Style determines the format of unit names (compact, short, long, + // long-and). // Default: FormatStyleShort. Style FormatStyle - // Separator is the string used between components (ignored if only one component). - // Default: ", " for long styles, " " for short style. + // Separator is the string used between components (ignored if only one + // component). + // Default: ", " for long styles, " " for short style, "" for compact style. Separator string - // Conjunction is the string used before the last component in "long-and" style. + // Conjunction is the string used before the last component in "long-and" + // style. // Default: " and ". Conjunction string } From aed8d3386a69e185c46ebc9341452b814212329b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Tue, 22 Apr 2025 11:50:30 +0200 Subject: [PATCH 04/16] fix: rename const for clarity --- xtime/const.go | 2 +- xtime/parse.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/xtime/const.go b/xtime/const.go index f76c3d0..ecd5562 100644 --- a/xtime/const.go +++ b/xtime/const.go @@ -1,5 +1,5 @@ package xtime const ( - float64Size = 64 + floatBitSize = 64 ) diff --git a/xtime/parse.go b/xtime/parse.go index 0b3816d..df25a4b 100644 --- a/xtime/parse.go +++ b/xtime/parse.go @@ -115,7 +115,7 @@ func parse(input string) (time.Duration, error) { // Handle single number input (interpreted as seconds) if len(tokens) == 1 { token := tokens[0] - seconds, err := strconv.ParseFloat(token, float64Size) + seconds, err := strconv.ParseFloat(token, floatBitSize) if err == nil { duration = time.Duration(seconds * float64(time.Second)) return duration, nil @@ -135,7 +135,7 @@ func parse(input string) (time.Duration, error) { valueStr := tokens[i] unitStr := tokens[i+1] - value, err := strconv.ParseFloat(valueStr, float64Size) + value, err := strconv.ParseFloat(valueStr, floatBitSize) if err != nil { msg := "expected a number" return 0, newParseTokenError(input, valueStr, msg, i) From 9dd443ce4c2f78842a3da491d83f503b5bcac458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Tue, 22 Apr 2025 11:50:47 +0200 Subject: [PATCH 05/16] fix: clarify sentence --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a853f24..ba90a40 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ comes in the form of a collection of packages. ## Dependencies -The packages are dependency free meaning packages added to this module must not -use any external dependencies unless listed below. +The packages are dependency free, meaning they must not use any external +dependencies unless explicitly listed.' Exceptions: From ebea5eb86362f5390efdd650dcba642f39146e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Tue, 22 Apr 2025 11:55:41 +0200 Subject: [PATCH 06/16] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ba90a40..7afd57e 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,7 @@ comes in the form of a collection of packages. ## Dependencies The packages are dependency free, meaning they must not use any external -dependencies unless explicitly listed.' - +dependencies unless explicitly listed. Exceptions: - `golang.org/x/*` - maintained by go and dependency free From d1dce1eaf8149585e0112680792b662f0e8ad8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Tue, 22 Apr 2025 11:52:19 +0200 Subject: [PATCH 07/16] refactor: remove version package (#16) This removed the version package. Having a version.First does not justify a package by itself. The function is not specific to versions and has been recreated as xstrings.Coalesce(). No need to deprecate as it is only used in one place. --- README.md | 1 - version/compare.go | 12 -------- version/compare_test.go | 49 ----------------------------- version/doc.go | 2 -- xstrings/funcs.go | 14 +++++++++ xstrings/funcs_test.go | 68 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 64 deletions(-) delete mode 100644 version/compare.go delete mode 100644 version/compare_test.go delete mode 100644 version/doc.go create mode 100644 xstrings/funcs.go create mode 100644 xstrings/funcs_test.go diff --git a/README.md b/README.md index 7afd57e..fea6af3 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ package mypkg - `file` - file operations - `set` - set data structure - `unit` - unit formatting and conversion package -- `version` - version functions - `xjson` - JSON functions - `xslices` - slice data type functions - `xstrings` - string data type functions diff --git a/version/compare.go b/version/compare.go deleted file mode 100644 index b0f983a..0000000 --- a/version/compare.go +++ /dev/null @@ -1,12 +0,0 @@ -package version - -// First returns the first non-empty version string from the provided list of -// versions. -func First(versions ...string) string { - for _, version := range versions { - if version != "" { - return version - } - } - return "" -} diff --git a/version/compare_test.go b/version/compare_test.go deleted file mode 100644 index a3a3efa..0000000 --- a/version/compare_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package version - -import ( - "testing" - - "github.com/neticdk/go-stdlib/assert" -) - -func TestFirst(t *testing.T) { - tests := []struct { - name string - versions []string - expected string - }{ - { - name: "No versions", - versions: []string{}, - expected: "", - }, - - { - name: "All empty versions", - versions: []string{"", "", ""}, - expected: "", - }, - { - name: "First non-empty version", - versions: []string{"", "1.0.0", "2.0.0"}, - expected: "1.0.0", - }, - { - name: "First version non-empty", - versions: []string{"1.0.0", "2.0.0", "3.0.0"}, - expected: "1.0.0", - }, - { - name: "Middle version non-empty", - versions: []string{"", "2.0.0", ""}, - expected: "2.0.0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := First(tt.versions...) - assert.Equal(t, result, tt.expected, "First/%q", tt.name) - }) - } -} diff --git a/version/doc.go b/version/doc.go deleted file mode 100644 index ec4cbee..0000000 --- a/version/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package version provides functionality to work with versions. -package version diff --git a/xstrings/funcs.go b/xstrings/funcs.go new file mode 100644 index 0000000..120ab99 --- /dev/null +++ b/xstrings/funcs.go @@ -0,0 +1,14 @@ +package xstrings + +import "github.com/neticdk/go-stdlib/xslices" + +// Coalesce returns the first non-empty string from the given slice. +func Coalesce(strs ...string) string { + s, found := xslices.FindFunc(strs, func(s string) bool { + return s != "" + }) + if found { + return s + } + return "" +} diff --git a/xstrings/funcs_test.go b/xstrings/funcs_test.go new file mode 100644 index 0000000..e2eccb8 --- /dev/null +++ b/xstrings/funcs_test.go @@ -0,0 +1,68 @@ +package xstrings_test + +import ( + "testing" + + "github.com/neticdk/go-stdlib/assert" + "github.com/neticdk/go-stdlib/xstrings" +) + +func TestCoalesce(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "Empty slice", + args: []string{}, + want: "", + }, + { + name: "All empty strings", + args: []string{"", "", ""}, + want: "", + }, + { + name: "First string non-empty", + args: []string{"first", "second", "third"}, + want: "first", + }, + { + name: "Second string non-empty", + args: []string{"", "second", "third"}, + want: "second", + }, + { + name: "Last string non-empty", + args: []string{"", "", "third"}, + want: "third", + }, + { + name: "Mixed empty and non-empty", + args: []string{"", "second", "", "fourth"}, + want: "second", + }, + { + name: "Single non-empty string", + args: []string{"single"}, + want: "single", + }, + { + name: "Single empty string", + args: []string{""}, + want: "", + }, + { + name: "Nil slice (variadic converts nil to empty)", + args: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := xstrings.Coalesce(tt.args...) + assert.Equal(t, got, tt.want, "Coalesce()/%s", tt.name) + }) + } +} From e9767eeb06393957fefe259fb8f6ff5ab4fa2b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20B=C3=B8gh=20Larsen=20=5BNetic=5D?= Date: Tue, 15 Apr 2025 13:39:05 +0200 Subject: [PATCH 08/16] feat(xstructs): initial custom tag order --- xstructs/map.go | 58 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/xstructs/map.go b/xstructs/map.go index e4b3af2..38e60ca 100644 --- a/xstructs/map.go +++ b/xstructs/map.go @@ -11,6 +11,30 @@ import ( // tagCategories is a list of tag categories to check for. var tagCategories = []string{"json", "yaml"} +func WithTags(tags ...string) toMapOptions { + return func(h *handler) { + h.tags = append(h.tags, tags...) + } +} + +type toMapOptions func(*handler) + +type handler struct { + tags []string +} + +func newHandler(opts ...toMapOptions) *handler { + h := &handler{ + tags: tagCategories, + } + + for _, opt := range opts { + opt(h) + } + + return h +} + // ToMap converts a struct or map to a map[string]any. // It handles nested structs, maps, and slices. // It uses the "json" and "yaml" tags to determine the key names. @@ -20,11 +44,13 @@ var tagCategories = []string{"json", "yaml"} // // If the input is nil, it returns nil. // If the input is not a struct or map, it returns an error. -func ToMap(obj any) (map[string]any, error) { +func ToMap(obj any, opts ...toMapOptions) (map[string]any, error) { + handler := newHandler(opts...) + if obj == nil { return nil, nil } - res := handle(obj) + res := handler.handle(obj) if v, ok := res.(map[string]any); ok { return v, nil } @@ -33,7 +59,7 @@ func ToMap(obj any) (map[string]any, error) { // handle is a helper function that recursively handles // the conversion of structs, maps, and slices to a map[string]any. -func handle(obj any) any { +func (h *handler) handle(obj any) any { if obj == nil { return nil } @@ -45,11 +71,11 @@ func handle(obj any) any { switch val.Kind() { case reflect.Map: - return handleMap(obj) + return h.handleMap(obj) case reflect.Struct: - return handleStruct(obj) + return h.handleStruct(obj) case reflect.Slice: - return handleSlice(obj) + return h.handleSlice(obj) default: return obj } @@ -57,7 +83,7 @@ func handle(obj any) any { // handleStruct handles the conversion of a struct to a map[string]any. // It uses the "json" and "yaml" tags to determine the key names. -func handleStruct(obj any) any { +func (h *handler) handleStruct(obj any) any { res := map[string]any{} val := reflect.ValueOf(obj) if val.Kind() == reflect.Ptr { @@ -74,7 +100,7 @@ func handleStruct(obj any) any { name := field.Name value := val.Field(i) - tagName, tagOpts := getTag(field) + tagName, tagOpts := h.getTag(field) if tagName != "" { name = tagName } @@ -105,7 +131,7 @@ func handleStruct(obj any) any { if _, ok := xslices.FindFunc(tagOpts, func(s string) bool { return s == "inline" }); ok { - if nestedValues, ok := handle(value.Interface()).(map[string]any); ok { + if nestedValues, ok := h.handle(value.Interface()).(map[string]any); ok { for k, v := range nestedValues { if _, ok := res[k]; !ok { res[k] = v @@ -116,7 +142,7 @@ func handleStruct(obj any) any { } } - res[name] = handle(value.Interface()) + res[name] = h.handle(value.Interface()) } return res @@ -124,7 +150,7 @@ func handleStruct(obj any) any { // handleMap handles the conversion of a map to a map[string]any, // recursively converting nested maps, slices and structs. -func handleMap(obj any) any { +func (h *handler) handleMap(obj any) any { m := map[string]any{} val := reflect.ValueOf(obj) for _, key := range val.MapKeys() { @@ -136,18 +162,18 @@ func handleMap(obj any) any { if v == nil { continue } - m[fmt.Sprintf("%v", k)] = handle(v) + m[fmt.Sprintf("%v", k)] = h.handle(v) } return m } // handleSlice handles the conversion of a slice to a slice of any, // recursively converting nested maps, slices and structs. -func handleSlice(obj any) any { +func (h *handler) handleSlice(obj any) any { s := []any{} val := reflect.ValueOf(obj) for i := range val.Len() { - s = append(s, handle(val.Index(i).Interface())) + s = append(s, h.handle(val.Index(i).Interface())) } return s } @@ -156,8 +182,8 @@ func handleSlice(obj any) any { // It checks for the "json" and "yaml" tags in that order. // If one tag is empty, it will return the other tag. // If both tags are empty, it returns an empty string and an empty slice. -func getTag(field reflect.StructField) (string, []string) { - for _, category := range tagCategories { +func (h *handler) getTag(field reflect.StructField) (string, []string) { + for _, category := range h.tags { if tag := field.Tag.Get(category); tag != "" { splitTag := strings.Split(tag, ",") return splitTag[0], splitTag[1:] From 4c7f3f78c3fdc76358b6a0b1310da37e3b9061b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20B=C3=B8gh=20Larsen=20=5BNetic=5D?= Date: Tue, 15 Apr 2025 13:46:22 +0200 Subject: [PATCH 09/16] fix: incorrect append --- xstructs/map.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xstructs/map.go b/xstructs/map.go index 38e60ca..8384fa8 100644 --- a/xstructs/map.go +++ b/xstructs/map.go @@ -13,7 +13,7 @@ var tagCategories = []string{"json", "yaml"} func WithTags(tags ...string) toMapOptions { return func(h *handler) { - h.tags = append(h.tags, tags...) + h.tags = tags } } From b49c777079da1a39ca8fe092e715d0242bdb0171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20B=C3=B8gh=20Larsen=20=5BNetic=5D?= Date: Tue, 15 Apr 2025 14:05:35 +0200 Subject: [PATCH 10/16] chore: new test cases and more doc --- xstructs/map.go | 77 +++++++++++++++++++------ xstructs/map_test.go | 131 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 17 deletions(-) diff --git a/xstructs/map.go b/xstructs/map.go index 8384fa8..daee92b 100644 --- a/xstructs/map.go +++ b/xstructs/map.go @@ -11,21 +11,52 @@ import ( // tagCategories is a list of tag categories to check for. var tagCategories = []string{"json", "yaml"} -func WithTags(tags ...string) toMapOptions { +// WithTags allows you to specify custom tag categories to check for. +// It can be used to override the default "json" and "yaml" tags. +// The tags are checked in the order they are provided. +func WithTags(tags ...string) ToMapOptions { return func(h *handler) { h.tags = tags } } -type toMapOptions func(*handler) +// WithAllowNoTags allows you to specify whether to allow fields without tags. +// If used, fields without tags will be included in the output map. +func WithAllowNoTags() ToMapOptions { + return func(h *handler) { + h.allowNoTags = true + } +} +// ToMapOptions is a function that modifies the handler. +type ToMapOptions func(*handler) + +// handler is a struct that contains the options for the ToMap function. +// It contains a list of tags to check for and a flag to allow fields +// without tags. type handler struct { - tags []string + tags []string + allowNoTags bool +} + +// tagWrapper is a struct that contains the name and options of a tag. +// It is used to store the tag information for a field. +// The name is the key name to use in the output map. +// The options are the options specified in the tag. +type tagWrapper struct { + Name string + Options []string } -func newHandler(opts ...toMapOptions) *handler { +// newHandler creates a new handler with the default options. +// It initializes the tags to the default "json" and "yaml" tags. +// It also initializes the allowNoTags flag to false. +// It can be modified using the ToMapOptions functions. +// It returns a pointer to the handler. +func newHandler(opts ...ToMapOptions) *handler { h := &handler{ - tags: tagCategories, + tags: tagCategories, + allowNoTags: false, } for _, opt := range opts { @@ -44,7 +75,7 @@ func newHandler(opts ...toMapOptions) *handler { // // If the input is nil, it returns nil. // If the input is not a struct or map, it returns an error. -func ToMap(obj any, opts ...toMapOptions) (map[string]any, error) { +func ToMap(obj any, opts ...ToMapOptions) (map[string]any, error) { handler := newHandler(opts...) if obj == nil { @@ -100,19 +131,30 @@ func (h *handler) handleStruct(obj any) any { name := field.Name value := val.Field(i) - tagName, tagOpts := h.getTag(field) - if tagName != "" { - name = tagName + tagInfo, err := h.getTag(field) + if err != nil && !h.allowNoTags { + continue + } + + if h.allowNoTags && tagInfo == nil { + tagInfo = &tagWrapper{ + Name: "", + Options: []string{}, + } + } + + if tagInfo.Name != "" { + name = tagInfo.Name } // Omit struct tag "-" - if _, ok := xslices.FindFunc(tagOpts, func(s string) bool { + if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool { return s == "-" - }); ok || (name == "-" && len(tagOpts) == 0) { + }); ok || (name == "-" && len(tagInfo.Options) == 0) { continue } - if _, ok := xslices.FindFunc(tagOpts, func(s string) bool { + if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool { return s == "omitempty" }); ok { if reflect.DeepEqual(value.Interface(), reflect.Zero(val.Field(i).Type()).Interface()) { @@ -128,7 +170,7 @@ func (h *handler) handleStruct(obj any) any { value = value.Elem() } if value.Kind() == reflect.Struct || value.Kind() == reflect.Map { - if _, ok := xslices.FindFunc(tagOpts, func(s string) bool { + if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool { return s == "inline" }); ok { if nestedValues, ok := h.handle(value.Interface()).(map[string]any); ok { @@ -182,12 +224,15 @@ func (h *handler) handleSlice(obj any) any { // It checks for the "json" and "yaml" tags in that order. // If one tag is empty, it will return the other tag. // If both tags are empty, it returns an empty string and an empty slice. -func (h *handler) getTag(field reflect.StructField) (string, []string) { +func (h *handler) getTag(field reflect.StructField) (*tagWrapper, error) { for _, category := range h.tags { if tag := field.Tag.Get(category); tag != "" { splitTag := strings.Split(tag, ",") - return splitTag[0], splitTag[1:] + return &tagWrapper{ + Name: splitTag[0], + Options: splitTag[1:], + }, nil } } - return "", []string{} + return nil, fmt.Errorf("no tag of %s found for field %s", strings.Join(h.tags, ", "), field.Name) } diff --git a/xstructs/map_test.go b/xstructs/map_test.go index 0ace554..6456dff 100644 --- a/xstructs/map_test.go +++ b/xstructs/map_test.go @@ -13,6 +13,7 @@ func TestToMap(t *testing.T) { tests := []struct { name string data any + opts []xstructs.ToMapOptions expected map[string]any wantErr bool }{ @@ -244,11 +245,139 @@ func TestToMap(t *testing.T) { expected: nil, wantErr: false, }, + { + name: "unexported field", + data: struct { + A int `json:"a"` + b string + }{ + A: 1, + b: "test", + }, + expected: map[string]any{ + "a": 1, + }, + wantErr: false, + }, + { + name: "with custom tag order", + data: struct { + A int `json:"-" yaml:"a"` + B string `json:"-" yaml:"b"` + C struct { + D int `json:"-" yaml:"d"` + } `json:"-" yaml:"c"` + E []string `json:"-" yaml:"e,omitempty"` + }{ + A: 1, + B: "test", + C: struct { + D int `json:"-" yaml:"d"` + }{ + D: 2, + }, + E: []string{"one", "two"}, + }, + opts: []xstructs.ToMapOptions{ + xstructs.WithTags("yaml", "json"), + }, + expected: map[string]any{ + "a": 1, + "b": "test", + "c": map[string]any{ + "d": 2, + }, + "e": []any{"one", "two"}, + }, + wantErr: false, + }, + { + name: "with custom tags none exist", + data: struct { + A int `json:"-" yaml:"a"` + B string `json:"-" yaml:"b"` + C struct { + D int `json:"-" yaml:"d"` + } `json:"-" yaml:"c"` + E []string `json:"-" yaml:"e,omitempty"` + }{ + A: 1, + B: "test", + C: struct { + D int `json:"-" yaml:"d"` + }{ + D: 2, + }, + E: []string{"one", "two"}, + }, + opts: []xstructs.ToMapOptions{ + xstructs.WithTags("custom"), + }, + expected: map[string]any{}, + wantErr: false, + }, + { + name: "with allow no tags", + data: struct { + A int + B string + }{ + A: 1, + B: "test", + }, + opts: []xstructs.ToMapOptions{ + xstructs.WithAllowNoTags(), + }, + expected: map[string]any{ + "A": 1, + "B": "test", + }, + wantErr: false, + }, + { + name: "with pointer value", + data: &struct { + A int `json:"a"` + B *struct { + C int `json:"c"` + } `json:"b"` + }{ + A: 1, + B: &struct { + C int `json:"c"` + }{ + C: 2, + }, + }, + expected: map[string]any{ + "a": 1, + "b": map[string]any{ + "c": 2, + }, + }, + wantErr: false, + }, + { + name: "with nil pointer value", + data: &struct { + A int `json:"a"` + B *struct { + C int `json:"c"` + } `json:"b"` + }{ + A: 1, + B: nil, + }, + expected: map[string]any{ + "a": 1, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := xstructs.ToMap(tt.data) + got, err := xstructs.ToMap(tt.data, tt.opts...) if tt.wantErr { assert.Error(t, err) } From a74a92d78d4eaf4b3845cef589203b190758027e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20B=C3=B8gh=20Larsen=20=5BNetic=5D?= Date: Wed, 16 Apr 2025 07:53:41 +0200 Subject: [PATCH 11/16] fix: review comments --- xstructs/map.go | 7 ++++++- xstructs/map_test.go | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/xstructs/map.go b/xstructs/map.go index daee92b..a8b519b 100644 --- a/xstructs/map.go +++ b/xstructs/map.go @@ -68,7 +68,8 @@ func newHandler(opts ...ToMapOptions) *handler { // ToMap converts a struct or map to a map[string]any. // It handles nested structs, maps, and slices. -// It uses the "json" and "yaml" tags to determine the key names. +// By default, it uses the "json" and "yaml" tags +// to determine the key names in that order. // It respects the `omitempty` tag for fields. // It respects the `inline` tag for nested structs. // It respects the `-` tag to omit fields. @@ -228,6 +229,10 @@ func (h *handler) getTag(field reflect.StructField) (*tagWrapper, error) { for _, category := range h.tags { if tag := field.Tag.Get(category); tag != "" { splitTag := strings.Split(tag, ",") + // Test if tag is solitary comma, i.e. `json:","` + if splitTag[0] == "" && len(splitTag[1]) == 0 { + return nil, fmt.Errorf("no tag of %s found for field %s", strings.Join(h.tags, ", "), field.Name) + } return &tagWrapper{ Name: splitTag[0], Options: splitTag[1:], diff --git a/xstructs/map_test.go b/xstructs/map_test.go index 6456dff..e640e2e 100644 --- a/xstructs/map_test.go +++ b/xstructs/map_test.go @@ -373,6 +373,16 @@ func TestToMap(t *testing.T) { }, wantErr: false, }, + { + name: "comma in tag", + data: struct { + A int `json:","` + }{ + A: 1, + }, + expected: map[string]any{}, + wantErr: false, + }, } for _, tt := range tests { From 44c231ac206f95c368858a93544402a62adf3480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20B=C3=B8gh=20Larsen=20=5BNetic=5D?= Date: Wed, 16 Apr 2025 07:55:39 +0200 Subject: [PATCH 12/16] fix: review comments --- xstructs/map.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xstructs/map.go b/xstructs/map.go index a8b519b..bd01df0 100644 --- a/xstructs/map.go +++ b/xstructs/map.go @@ -114,7 +114,7 @@ func (h *handler) handle(obj any) any { } // handleStruct handles the conversion of a struct to a map[string]any. -// It uses the "json" and "yaml" tags to determine the key names. +// It uses the tags from the handler to determine the key names. func (h *handler) handleStruct(obj any) any { res := map[string]any{} val := reflect.ValueOf(obj) @@ -222,16 +222,16 @@ func (h *handler) handleSlice(obj any) any { } // getTag retrieves the tag name and options from a struct field. -// It checks for the "json" and "yaml" tags in that order. +// It checks the tags provided by the handler one by one. // If one tag is empty, it will return the other tag. -// If both tags are empty, it returns an empty string and an empty slice. +// If all tags are empty, it returns an error. func (h *handler) getTag(field reflect.StructField) (*tagWrapper, error) { for _, category := range h.tags { if tag := field.Tag.Get(category); tag != "" { splitTag := strings.Split(tag, ",") // Test if tag is solitary comma, i.e. `json:","` if splitTag[0] == "" && len(splitTag[1]) == 0 { - return nil, fmt.Errorf("no tag of %s found for field %s", strings.Join(h.tags, ", "), field.Name) + continue } return &tagWrapper{ Name: splitTag[0], From 4134a60bb606e550f1fe2991329ac8fd0c82694c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20B=C3=B8gh=20Larsen=20=5BNetic=5D?= Date: Tue, 22 Apr 2025 11:56:09 +0200 Subject: [PATCH 13/16] chore: rename ToMapOptions to ToMapOption --- xstructs/map.go | 12 ++++++------ xstructs/map_test.go | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/xstructs/map.go b/xstructs/map.go index bd01df0..6939925 100644 --- a/xstructs/map.go +++ b/xstructs/map.go @@ -14,7 +14,7 @@ var tagCategories = []string{"json", "yaml"} // WithTags allows you to specify custom tag categories to check for. // It can be used to override the default "json" and "yaml" tags. // The tags are checked in the order they are provided. -func WithTags(tags ...string) ToMapOptions { +func WithTags(tags ...string) ToMapOption { return func(h *handler) { h.tags = tags } @@ -22,14 +22,14 @@ func WithTags(tags ...string) ToMapOptions { // WithAllowNoTags allows you to specify whether to allow fields without tags. // If used, fields without tags will be included in the output map. -func WithAllowNoTags() ToMapOptions { +func WithAllowNoTags() ToMapOption { return func(h *handler) { h.allowNoTags = true } } -// ToMapOptions is a function that modifies the handler. -type ToMapOptions func(*handler) +// ToMapOption is a function that modifies the handler. +type ToMapOption func(*handler) // handler is a struct that contains the options for the ToMap function. // It contains a list of tags to check for and a flag to allow fields @@ -53,7 +53,7 @@ type tagWrapper struct { // It also initializes the allowNoTags flag to false. // It can be modified using the ToMapOptions functions. // It returns a pointer to the handler. -func newHandler(opts ...ToMapOptions) *handler { +func newHandler(opts ...ToMapOption) *handler { h := &handler{ tags: tagCategories, allowNoTags: false, @@ -76,7 +76,7 @@ func newHandler(opts ...ToMapOptions) *handler { // // If the input is nil, it returns nil. // If the input is not a struct or map, it returns an error. -func ToMap(obj any, opts ...ToMapOptions) (map[string]any, error) { +func ToMap(obj any, opts ...ToMapOption) (map[string]any, error) { handler := newHandler(opts...) if obj == nil { diff --git a/xstructs/map_test.go b/xstructs/map_test.go index e640e2e..cc5cc1b 100644 --- a/xstructs/map_test.go +++ b/xstructs/map_test.go @@ -13,7 +13,7 @@ func TestToMap(t *testing.T) { tests := []struct { name string data any - opts []xstructs.ToMapOptions + opts []xstructs.ToMapOption expected map[string]any wantErr bool }{ @@ -278,7 +278,7 @@ func TestToMap(t *testing.T) { }, E: []string{"one", "two"}, }, - opts: []xstructs.ToMapOptions{ + opts: []xstructs.ToMapOption{ xstructs.WithTags("yaml", "json"), }, expected: map[string]any{ @@ -310,7 +310,7 @@ func TestToMap(t *testing.T) { }, E: []string{"one", "two"}, }, - opts: []xstructs.ToMapOptions{ + opts: []xstructs.ToMapOption{ xstructs.WithTags("custom"), }, expected: map[string]any{}, @@ -325,7 +325,7 @@ func TestToMap(t *testing.T) { A: 1, B: "test", }, - opts: []xstructs.ToMapOptions{ + opts: []xstructs.ToMapOption{ xstructs.WithAllowNoTags(), }, expected: map[string]any{ From 56d6a77af18b1cd900a6be16899bd5752b64e53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Tue, 22 Apr 2025 13:18:37 +0200 Subject: [PATCH 14/16] fix: use time.Nanosecond as comparison value for consistency --- xtime/duration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xtime/duration.go b/xtime/duration.go index 12a7011..8c8aeaf 100644 --- a/xtime/duration.go +++ b/xtime/duration.go @@ -112,7 +112,7 @@ type FormatOption func(*FormatOptions) // WithMaxUnit sets the largest unit to display. func WithMaxUnit(unit time.Duration) FormatOption { return func(o *FormatOptions) { - if unit > 0 { + if unit >= time.Nanosecond { o.MaxUnit = unit } } From 8ce89db7ad22a3097ef736cd340f690b10548343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Tue, 22 Apr 2025 13:19:04 +0200 Subject: [PATCH 15/16] fix: remove unnecessary function --- xtime/duration.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/xtime/duration.go b/xtime/duration.go index 8c8aeaf..d4565cb 100644 --- a/xtime/duration.go +++ b/xtime/duration.go @@ -134,13 +134,6 @@ func WithRounding() FormatOption { } } -// WithoutRounding disables rounding of the MinUnit. -func WithoutRounding() FormatOption { - return func(o *FormatOptions) { - o.Rounding = false - } -} - // WithMaxComponents sets the max number of components. 0 means unlimited. func WithMaxComponents(p int) FormatOption { return func(o *FormatOptions) { From c83c9eb47428ce7139b0412ca1d7a566a548b720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20N=C3=B8rgaard?= Date: Tue, 22 Apr 2025 13:19:28 +0200 Subject: [PATCH 16/16] refactor: rename function to further indicate its inaccuracy --- xtime/duration.go | 4 ++-- xtime/duration_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/xtime/duration.go b/xtime/duration.go index d4565cb..2edf28c 100644 --- a/xtime/duration.go +++ b/xtime/duration.go @@ -328,7 +328,7 @@ func findUnitDef(unit time.Duration) (timeUnitDef, bool) { return timeUnitDef{}, false } -// YearsFromDuration converts a duration into an approximate number of years. +// ApproxYearsFromDuration converts a duration into an approximate number of years. // It calculates this based on the average length of a year in the // Gregorian calendar (365.2425 days). // @@ -337,7 +337,7 @@ func findUnitDef(unit time.Duration) (timeUnitDef, bool) { // occurrences within a specific period, or time zones. For calendar-accurate // differences involving years and months, use functions operating on time.Time // values. -func YearsFromDuration(d time.Duration) float64 { +func ApproxYearsFromDuration(d time.Duration) float64 { return float64(d) / float64(YearApprox) } diff --git a/xtime/duration_test.go b/xtime/duration_test.go index 281e925..2ab41af 100644 --- a/xtime/duration_test.go +++ b/xtime/duration_test.go @@ -292,7 +292,7 @@ func TestYearsFromDuration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := xtime.YearsFromDuration(tt.duration) + got := xtime.ApproxYearsFromDuration(tt.duration) assert.InDelta(t, got, tt.expected, delta) }) }