From bcc19167d46fe0441ec099c35d1111bfcaffae2c Mon Sep 17 00:00:00 2001 From: yusing Date: Thu, 24 Apr 2025 06:27:32 +0800 Subject: [PATCH] feat: enhanced string utilities - relative time formatting - better relative duration formatting --- internal/utils/strutils/format.go | 203 +++++++++++++++++------ internal/utils/strutils/format_test.go | 215 +++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 48 deletions(-) create mode 100644 internal/utils/strutils/format_test.go diff --git a/internal/utils/strutils/format.go b/internal/utils/strutils/format.go index 626b701e..a4f675da 100644 --- a/internal/utils/strutils/format.go +++ b/internal/utils/strutils/format.go @@ -4,13 +4,39 @@ import ( "fmt" "math" "strconv" - "strings" "time" "github.com/yusing/go-proxy/internal/utils/strutils/ansi" ) -func FormatDuration(d time.Duration) string { +// AppendDuration appends a duration to a buffer with the following format: +// - 1 ns +// - 1 ms +// - 1 seconds +// - 1 minutes and 1 seconds +// - 1 hours, 1 minutes and 1 seconds +// - 1 days, 1 hours and 1 minutes (ignore seconds if days >= 1) +func AppendDuration(d time.Duration, buf []byte) []byte { + if d < 0 { + buf = append(buf, '-') + d = -d + } + + if d == 0 { + return append(buf, []byte("0 Seconds")...) + } + + switch { + case d < time.Millisecond: + buf = strconv.AppendInt(buf, int64(d.Nanoseconds()), 10) + buf = append(buf, []byte(" ns")...) + return buf + case d < time.Second: + buf = strconv.AppendInt(buf, int64(d.Milliseconds()), 10) + buf = append(buf, []byte(" ms")...) + return buf + } + // Get total seconds from duration totalSeconds := int64(d.Seconds()) @@ -20,30 +46,41 @@ func FormatDuration(d time.Duration) string { minutes := (totalSeconds % 3600) / 60 seconds := totalSeconds % 60 - // Create a slice to hold parts of the duration - var parts []string - + idxPartBeg := 0 if days > 0 { - parts = append(parts, fmt.Sprintf("%d day%s", days, pluralize(days))) + buf = strconv.AppendInt(buf, days, 10) + buf = fmt.Appendf(buf, " day%s, ", Pluralize(days)) } if hours > 0 { - parts = append(parts, fmt.Sprintf("%d hour%s", hours, pluralize(hours))) + idxPartBeg = len(buf) - 2 + buf = strconv.AppendInt(buf, hours, 10) + buf = fmt.Appendf(buf, " hour%s, ", Pluralize(hours)) } if minutes > 0 { - parts = append(parts, fmt.Sprintf("%d minute%s", minutes, pluralize(minutes))) + idxPartBeg = len(buf) - 2 + buf = strconv.AppendInt(buf, minutes, 10) + buf = fmt.Appendf(buf, " minute%s, ", Pluralize(minutes)) } if seconds > 0 && totalSeconds < 3600 { - parts = append(parts, fmt.Sprintf("%d second%s", seconds, pluralize(seconds))) + idxPartBeg = len(buf) - 2 + buf = strconv.AppendInt(buf, seconds, 10) + buf = fmt.Appendf(buf, " second%s, ", Pluralize(seconds)) } + // remove last comma and space + buf = buf[:len(buf)-2] + if idxPartBeg > 0 && idxPartBeg < len(buf) { + // replace last part ', ' with ' and ' in-place, alloc-free + // ', ' is 2 bytes, ' and ' is 5 bytes, so we need to make room for 3 more bytes + tailLen := len(buf) - (idxPartBeg + 2) + buf = append(buf, "000"...) // append 3 bytes for ' and ' + copy(buf[idxPartBeg+5:], buf[idxPartBeg+2:idxPartBeg+2+tailLen]) // shift tail right by 3 + copy(buf[idxPartBeg:], " and ") // overwrite ', ' with ' and ' + } + return buf +} - // Join the parts with appropriate connectors - if len(parts) == 0 { - return "0 Seconds" - } - if len(parts) == 1 { - return parts[0] - } - return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1] +func FormatDuration(d time.Duration) string { + return string(AppendDuration(d, nil)) } func FormatLastSeen(t time.Time) string { @@ -53,28 +90,93 @@ func FormatLastSeen(t time.Time) string { return FormatTime(t) } -func FormatTime(t time.Time) string { - return t.Format("2006-01-02 15:04:05") +func appendRound(f float64, buf []byte) []byte { + return strconv.AppendInt(buf, int64(math.Round(f)), 10) } -func ParseBool(s string) bool { - switch strings.ToLower(s) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func formatFloat(f float64) string { +func appendFloat(f float64, buf []byte) []byte { f = math.Round(f*100) / 100 if f == 0 { - return "0" + return buf } - return strconv.FormatFloat(f, 'f', -1, 64) + return strconv.AppendFloat(buf, f, 'f', -1, 64) } -func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string) { +func AppendTime(t time.Time, buf []byte) []byte { + if t.IsZero() { + return append(buf, []byte("never")...) + } + return AppendTimeWithReference(t, time.Now(), buf) +} + +func FormatTime(t time.Time) string { + return string(AppendTime(t, nil)) +} + +func FormatUnixTime(t int64) string { + return FormatTime(time.Unix(t, 0)) +} + +func FormatTimeWithReference(t, ref time.Time) string { + return string(AppendTimeWithReference(t, ref, nil)) +} + +func AppendTimeWithReference(t, ref time.Time, buf []byte) []byte { + if t.IsZero() { + return append(buf, []byte("never")...) + } + diff := t.Sub(ref) + absDiff := diff.Abs() + switch { + case absDiff < time.Second: + return append(buf, []byte("now")...) + case absDiff < 3*time.Second: + if diff < 0 { + return append(buf, []byte("just now")...) + } + fallthrough + case absDiff < 60*time.Second: + if diff < 0 { + buf = appendRound(absDiff.Seconds(), buf) + buf = append(buf, []byte(" seconds ago")...) + } else { + buf = append(buf, []byte("in ")...) + buf = appendRound(absDiff.Seconds(), buf) + buf = append(buf, []byte(" seconds")...) + } + return buf + case absDiff < 60*time.Minute: + if diff < 0 { + buf = appendRound(absDiff.Minutes(), buf) + buf = append(buf, []byte(" minutes ago")...) + } else { + buf = append(buf, []byte("in ")...) + buf = appendRound(absDiff.Minutes(), buf) + buf = append(buf, []byte(" minutes")...) + } + return buf + case absDiff < 24*time.Hour: + if diff < 0 { + buf = appendRound(absDiff.Hours(), buf) + buf = append(buf, []byte(" hours ago")...) + } else { + buf = append(buf, []byte("in ")...) + buf = appendRound(absDiff.Hours(), buf) + buf = append(buf, []byte(" hours")...) + } + return buf + case t.Year() == ref.Year(): + return t.AppendFormat(buf, "01-02 15:04:05") + default: + return t.AppendFormat(buf, "2006-01-02 15:04:05") + } +} + +func FormatByteSize[T ~int | ~uint | ~int64 | ~uint64 | ~float64](size T) string { + return string(AppendByteSize(size, nil)) +} + +func AppendByteSize[T ~int | ~uint | ~int64 | ~uint64 | ~float64](size T, buf []byte) []byte { const ( _ = (1 << (10 * iota)) kb @@ -85,27 +187,32 @@ func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string) ) switch { case size < kb: - return fmt.Sprintf("%v", size), "B" + switch any(size).(type) { + case int, int64: + buf = strconv.AppendInt(buf, int64(size), 10) + case uint, uint64: + buf = strconv.AppendUint(buf, uint64(size), 10) + case float64: + buf = appendFloat(float64(size), buf) + } + buf = append(buf, []byte(" B")...) case size < mb: - return formatFloat(float64(size) / kb), "KiB" + buf = appendFloat(float64(size)/kb, buf) + buf = append(buf, []byte(" KiB")...) case size < gb: - return formatFloat(float64(size) / mb), "MiB" + buf = appendFloat(float64(size)/mb, buf) + buf = append(buf, []byte(" MiB")...) case size < tb: - return formatFloat(float64(size) / gb), "GiB" + buf = appendFloat(float64(size)/gb, buf) + buf = append(buf, []byte(" GiB")...) case size < pb: - return formatFloat(float64(size/gb) / kb), "TiB" // prevent overflow + buf = appendFloat(float64(size/gb)/kb, buf) + buf = append(buf, []byte(" TiB")...) default: - return formatFloat(float64(size/tb) / kb), "PiB" // prevent overflow + buf = appendFloat(float64(size/tb)/kb, buf) + buf = append(buf, []byte(" PiB")...) } -} - -func FormatByteSizeWithUnit[T ~int64 | ~uint64 | ~float64](size T) string { - value, unit := FormatByteSize(size) - return value + " " + unit -} - -func PortString(port uint16) string { - return strconv.FormatUint(uint64(port), 10) + return buf } func DoYouMean(s string) string { @@ -115,7 +222,7 @@ func DoYouMean(s string) string { return "Did you mean " + ansi.HighlightGreen + s + ansi.Reset + "?" } -func pluralize(n int64) string { +func Pluralize(n int64) string { if n > 1 { return "s" } diff --git a/internal/utils/strutils/format_test.go b/internal/utils/strutils/format_test.go new file mode 100644 index 00000000..5fa5e6b1 --- /dev/null +++ b/internal/utils/strutils/format_test.go @@ -0,0 +1,215 @@ +package strutils_test + +import ( + "testing" + "time" + + . "github.com/yusing/go-proxy/internal/utils/strutils" + expect "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestFormatTime(t *testing.T) { + now := expect.Must(time.Parse(time.RFC3339, "2021-06-15T12:30:30Z")) + + tests := []struct { + name string + time time.Time + expected string + expectedLength int + }{ + { + name: "now", + time: now.Add(100 * time.Millisecond), + expected: "now", + }, + { + name: "just now (past within 3 seconds)", + time: now.Add(-1 * time.Second), + expected: "just now", + }, + { + name: "seconds ago", + time: now.Add(-10 * time.Second), + expected: "10 seconds ago", + }, + { + name: "in seconds", + time: now.Add(10 * time.Second), + expected: "in 10 seconds", + }, + { + name: "minutes ago", + time: now.Add(-10 * time.Minute), + expected: "10 minutes ago", + }, + { + name: "in minutes", + time: now.Add(10 * time.Minute), + expected: "in 10 minutes", + }, + { + name: "hours ago", + time: now.Add(-10 * time.Hour), + expected: "10 hours ago", + }, + { + name: "in hours", + time: now.Add(10 * time.Hour), + expected: "in 10 hours", + }, + { + name: "different day", + time: now.Add(-25 * time.Hour), + expectedLength: len("01-01 15:04:05"), + }, + { + name: "same year but different month", + time: now.Add(-30 * 24 * time.Hour), + expectedLength: len("01-01 15:04:05"), + }, + { + name: "different year", + time: time.Date(now.Year()-1, 1, 1, 10, 20, 30, 0, now.Location()), + expected: time.Date(now.Year()-1, 1, 1, 10, 20, 30, 0, now.Location()).Format("2006-01-02 15:04:05"), + }, + { + name: "zero time", + time: time.Time{}, + expected: "never", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatTimeWithReference(tt.time, now) + + if tt.expectedLength > 0 { + expect.Equal(t, len(result), tt.expectedLength, result) + } else { + expect.Equal(t, result, tt.expected) + } + }) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + { + name: "zero duration", + duration: 0, + expected: "0 Seconds", + }, + { + name: "seconds only", + duration: 45 * time.Second, + expected: "45 seconds", + }, + { + name: "one second", + duration: 1 * time.Second, + expected: "1 second", + }, + { + name: "minutes only", + duration: 5 * time.Minute, + expected: "5 minutes", + }, + { + name: "one minute", + duration: 1 * time.Minute, + expected: "1 minute", + }, + { + name: "hours only", + duration: 3 * time.Hour, + expected: "3 hours", + }, + { + name: "one hour", + duration: 1 * time.Hour, + expected: "1 hour", + }, + { + name: "days only", + duration: 2 * 24 * time.Hour, + expected: "2 days", + }, + { + name: "one day", + duration: 24 * time.Hour, + expected: "1 day", + }, + { + name: "complex duration", + duration: 2*24*time.Hour + 3*time.Hour + 45*time.Minute + 15*time.Second, + expected: "2 days, 3 hours and 45 minutes", + }, + { + name: "hours and minutes", + duration: 2*time.Hour + 30*time.Minute, + expected: "2 hours and 30 minutes", + }, + { + name: "days and hours", + duration: 1*24*time.Hour + 12*time.Hour, + expected: "1 day and 12 hours", + }, + { + name: "days and hours and minutes", + duration: 1*24*time.Hour + 12*time.Hour + 30*time.Minute, + expected: "1 day, 12 hours and 30 minutes", + }, + { + name: "days and hours and minutes and seconds (ignore seconds)", + duration: 1*24*time.Hour + 12*time.Hour + 30*time.Minute + 15*time.Second, + expected: "1 day, 12 hours and 30 minutes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatDuration(tt.duration) + expect.Equal(t, result, tt.expected) + }) + } +} + +func TestFormatLastSeen(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + time time.Time + expected string + }{ + { + name: "zero time", + time: time.Time{}, + expected: "never", + }, + { + name: "non-zero time", + time: now.Add(-10 * time.Minute), + // The actual result will be handled by FormatTime, which is tested separately + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatLastSeen(tt.time) + + if tt.name == "zero time" { + expect.Equal(t, result, tt.expected) + } else { + // Just make sure it's not "never", the actual formatting is tested in TestFormatTime + if result == "never" { + t.Errorf("Expected non-zero time to not return 'never', got %s", result) + } + } + }) + } +}