package cli import ( "errors" "fmt" "regexp" "strconv" "strings" "time" ) // Approximate lengths of the extended calendar units accepted by // parseDuration. const ( durationDay = 24 * time.Hour durationWeek = 7 * durationDay durationMonth = 30 * durationDay durationYear = 365 * durationDay ) var ( errNegativeDuration = errors.New("negative durations are not supported") errInvalidDuration = errors.New("invalid duration format") errUnknownTimeUnit = errors.New("unknown time unit") ) // parseDuration parses duration strings. Supports standard Go duration format // (e.g., "3h30m", "1h45m30s") as well as extended units: // - d: days (e.g., "30d", "7d") // - w: weeks (e.g., "2w", "4w") // - mo: months (30 days) (e.g., "6mo", "1mo") // - y: years (365 days) (e.g., "1y", "2y") // // Can combine units: "1y6mo", "2w3d", "1d12h30m" func parseDuration(s string) (time.Duration, error) { // First try standard Go duration parsing d, err := time.ParseDuration(s) if err == nil { return d, nil } // Extended duration parsing // Check for negative values if strings.HasPrefix(strings.TrimSpace(s), "-") { return 0, errNegativeDuration } // Pattern matches: number + unit, repeated re := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*([a-zA-Z]+)`) matches := re.FindAllStringSubmatch(s, -1) if len(matches) == 0 { return 0, fmt.Errorf("%w: %q", errInvalidDuration, s) } var total time.Duration for _, match := range matches { valueStr := match[1] unit := strings.ToLower(match[2]) value, err := strconv.ParseFloat(valueStr, 64) if err != nil { return 0, fmt.Errorf("invalid number %q: %w", valueStr, err) } d, err := durationForUnit(value, unit) if err != nil { return 0, err } total += d } return total, nil } // durationForUnit converts a value with a (case-normalized) unit suffix // into a time.Duration, accepting Go's standard units plus the extended // calendar units. func durationForUnit(value float64, unit string) (time.Duration, error) { switch unit { // Standard time units case "ns", "nanosecond", "nanoseconds": return time.Duration(value), nil case "us", "µs", "microsecond", "microseconds": return time.Duration(value * float64(time.Microsecond)), nil case "ms", "millisecond", "milliseconds": return time.Duration(value * float64(time.Millisecond)), nil case "s", "sec", "second", "seconds": return time.Duration(value * float64(time.Second)), nil case "m", "min", "minute", "minutes": return time.Duration(value * float64(time.Minute)), nil case "h", "hr", "hour", "hours": return time.Duration(value * float64(time.Hour)), nil // Extended units case "d", "day", "days": return time.Duration(value * float64(durationDay)), nil case "w", "week", "weeks": return time.Duration(value * float64(durationWeek)), nil case "mo", "month", "months": // Using 30 days as approximation return time.Duration(value * float64(durationMonth)), nil case "y", "year", "years": // Using 365 days as approximation return time.Duration(value * float64(durationYear)), nil default: // Try parsing as standard Go duration unit testStr := "1" + unit _, err := time.ParseDuration(testStr) if err != nil { return 0, fmt.Errorf("%w: %q", errUnknownTimeUnit, unit) } // It's a valid Go duration unit, parse the full value fullStr := fmt.Sprintf("%g%s", value, unit) d, err := time.ParseDuration(fullStr) if err != nil { return 0, fmt.Errorf("invalid duration %q: %w", fullStr, err) } return d, nil } }