| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package confopt |
| 4 | |
| 5 | import ( |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "regexp" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | var reDuration = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|mo|m|h|d|wk|w|M|y)`) |
| 15 | |
| 16 | // ParseDuration parses a duration string with units. |
| 17 | func ParseDuration(s string) (time.Duration, error) { |
| 18 | orig := s |
| 19 | |
| 20 | if s = strings.ReplaceAll(s, " ", ""); s == "" { |
| 21 | return 0, fmt.Errorf("empty duration string") |
| 22 | } |
| 23 | |
| 24 | neg := s[0] == '-' |
| 25 | if neg { |
| 26 | s = s[1:] |
| 27 | } |
| 28 | |
| 29 | unitMap := map[string]time.Duration{ |
| 30 | "d": 24 * time.Hour, |
| 31 | "w": 7 * 24 * time.Hour, |
| 32 | "wk": 7 * 24 * time.Hour, |
| 33 | "mo": 30 * 24 * time.Hour, |
| 34 | "M": 30 * 24 * time.Hour, |
| 35 | "y": 365 * 24 * time.Hour, |
| 36 | } |
| 37 | |
| 38 | matches := reDuration.FindAllStringSubmatch(s, -1) |
| 39 | |
| 40 | if len(matches) == 0 { |
| 41 | return 0, fmt.Errorf("invalid duration format: '%s'", orig) |
| 42 | } |
| 43 | |
| 44 | var total time.Duration |
| 45 | |
| 46 | for _, m := range matches { |
| 47 | value, unit := m[1], m[2] |
| 48 | |
| 49 | val, err := strconv.ParseFloat(value, 64) |
| 50 | if err != nil { |
| 51 | return 0, fmt.Errorf("invalid number: %s", value) |
| 52 | } |
| 53 | |
| 54 | if multiplier, ok := unitMap[unit]; ok { |
| 55 | total += time.Duration(val * float64(multiplier)) |
| 56 | } else { |
| 57 | dur, err := time.ParseDuration(value + unit) |
| 58 | if err != nil { |
| 59 | return 0, fmt.Errorf("invalid duration unit: %s", value+unit) |
| 60 | } |
| 61 | total += dur |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | if neg { |
| 66 | total = -total |
| 67 | } |
| 68 | |
| 69 | return total, nil |
| 70 | } |
| 71 | |
| 72 | type Duration time.Duration |
| 73 | |
| 74 | func (d Duration) Duration() time.Duration { |
| 75 | return time.Duration(d) |
| 76 | } |
| 77 | |
| 78 | func (d Duration) String() string { |
| 79 | return d.Duration().String() |
| 80 | } |
| 81 | |
| 82 | func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error { |
| 83 | var s string |
| 84 | |
| 85 | if err := unmarshal(&s); err != nil { |
| 86 | return err |
| 87 | } |
| 88 | |
| 89 | if v, err := ParseDuration(s); err == nil { |
| 90 | *d = Duration(v) |
| 91 | return nil |
| 92 | } |
| 93 | if v, err := strconv.ParseInt(s, 10, 64); err == nil { |
| 94 | *d = Duration(time.Duration(v) * time.Second) |
| 95 | return nil |
| 96 | } |
| 97 | if v, err := strconv.ParseFloat(s, 64); err == nil { |
| 98 | *d = Duration(v * float64(time.Second)) |
| 99 | return nil |
| 100 | } |
| 101 | |
| 102 | return fmt.Errorf("unparsable duration format '%s'", s) |
| 103 | } |
| 104 | |
| 105 | func (d Duration) MarshalYAML() (any, error) { |
| 106 | seconds := float64(d) / float64(time.Second) |
| 107 | return seconds, nil |
| 108 | } |
| 109 | |
| 110 | func (d *Duration) UnmarshalJSON(b []byte) error { |
| 111 | // Try as JSON string first (handles quoted values like "30m", "5s") |
| 112 | var s string |
| 113 | if err := json.Unmarshal(b, &s); err == nil { |
| 114 | if v, err := ParseDuration(s); err == nil { |
| 115 | *d = Duration(v) |
| 116 | return nil |
| 117 | } |
| 118 | // Try as numeric string (interpret as seconds) |
| 119 | if v, err := strconv.ParseFloat(s, 64); err == nil { |
| 120 | *d = Duration(v * float64(time.Second)) |
| 121 | return nil |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // Try as JSON number (handles unquoted values like 5, 1.5) |
| 126 | var f float64 |
| 127 | if err := json.Unmarshal(b, &f); err == nil { |
| 128 | *d = Duration(f * float64(time.Second)) |
| 129 | return nil |
| 130 | } |
| 131 | |
| 132 | return fmt.Errorf("unparsable duration format '%s'", string(b)) |
| 133 | } |
| 134 | |
| 135 | func (d Duration) MarshalJSON() ([]byte, error) { |
| 136 | seconds := float64(d) / float64(time.Second) |
| 137 | return json.Marshal(seconds) |
| 138 | } |
| 139 | |
| 140 | // LongDuration is like Duration but marshals to a human-friendly string (e.g., "12h", "30m", "1d"). |
| 141 | // Unmarshal accepts both strings ("12h", "1d") and numbers (seconds). |
| 142 | type LongDuration time.Duration |
| 143 | |
| 144 | func (d LongDuration) Duration() time.Duration { |
| 145 | return time.Duration(d) |
| 146 | } |
| 147 | |
| 148 | func (d LongDuration) String() string { |
| 149 | return formatDuration(time.Duration(d)) |
| 150 | } |
| 151 | |
| 152 | func (d *LongDuration) UnmarshalYAML(unmarshal func(any) error) error { |
| 153 | var tmp Duration |
| 154 | if err := tmp.UnmarshalYAML(unmarshal); err != nil { |
| 155 | return err |
| 156 | } |
| 157 | *d = LongDuration(tmp) |
| 158 | return nil |
| 159 | } |
| 160 | |
| 161 | func (d LongDuration) MarshalYAML() (any, error) { |
| 162 | return formatDuration(time.Duration(d)), nil |
| 163 | } |
| 164 | |
| 165 | func (d *LongDuration) UnmarshalJSON(b []byte) error { |
| 166 | var tmp Duration |
| 167 | if err := tmp.UnmarshalJSON(b); err != nil { |
| 168 | return err |
| 169 | } |
| 170 | *d = LongDuration(tmp) |
| 171 | return nil |
| 172 | } |
| 173 | |
| 174 | func (d LongDuration) MarshalJSON() ([]byte, error) { |
| 175 | return json.Marshal(formatDuration(time.Duration(d))) |
| 176 | } |
| 177 | |
| 178 | // formatDuration formats a duration as a human-friendly string. |
| 179 | // Uses the largest unit that produces a clean integer value, with preference |
| 180 | // for fractional seconds over milliseconds when value >= 1s. |
| 181 | // Supported units: y (365d), mo (30d), w (7d), d (24h), h, m, s, ms. |
| 182 | func formatDuration(d time.Duration) string { |
| 183 | if d == 0 { |
| 184 | return "0s" |
| 185 | } |
| 186 | |
| 187 | neg := d < 0 |
| 188 | if neg { |
| 189 | d = -d |
| 190 | } |
| 191 | |
| 192 | // Units from largest to smallest (excluding ms - handled separately) |
| 193 | units := []struct { |
| 194 | suffix string |
| 195 | value time.Duration |
| 196 | }{ |
| 197 | {"y", 365 * 24 * time.Hour}, |
| 198 | {"mo", 30 * 24 * time.Hour}, |
| 199 | {"w", 7 * 24 * time.Hour}, |
| 200 | {"d", 24 * time.Hour}, |
| 201 | {"h", time.Hour}, |
| 202 | {"m", time.Minute}, |
| 203 | {"s", time.Second}, |
| 204 | } |
| 205 | |
| 206 | // Find the largest unit that divides evenly |
| 207 | for _, u := range units { |
| 208 | if d >= u.value && d%u.value == 0 { |
| 209 | val := d / u.value |
| 210 | if neg { |
| 211 | return fmt.Sprintf("-%d%s", val, u.suffix) |
| 212 | } |
| 213 | return fmt.Sprintf("%d%s", val, u.suffix) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // For values >= 1s without clean division, prefer fractional seconds over ms |
| 218 | if d >= time.Second { |
| 219 | seconds := float64(d) / float64(time.Second) |
| 220 | if neg { |
| 221 | return fmt.Sprintf("-%.3gs", seconds) |
| 222 | } |
| 223 | return fmt.Sprintf("%.3gs", seconds) |
| 224 | } |
| 225 | |
| 226 | // For sub-second values, try milliseconds |
| 227 | if d >= time.Millisecond && d%time.Millisecond == 0 { |
| 228 | val := d / time.Millisecond |
| 229 | if neg { |
| 230 | return fmt.Sprintf("-%dms", val) |
| 231 | } |
| 232 | return fmt.Sprintf("%dms", val) |
| 233 | } |
| 234 | |
| 235 | // Fallback to Go's standard format for sub-millisecond precision |
| 236 | if neg { |
| 237 | return "-" + d.String() |
| 238 | } |
| 239 | return d.String() |
| 240 | } |