44import java .time .Instant ;
55import java .util .Locale ;
66import java .util .Objects ;
7+ import java .util .concurrent .TimeUnit ;
78import java .util .regex .Matcher ;
89import java .util .regex .Pattern ;
910
2021 * <li>1d</li>
2122 * <li>2w</li>
2223 * <li>1mo (treated as a fixed 30 days)</li>
23- * <li>Combined tokens: 1h30m, 2d12h, 1w2d3h4m5s6ms (spaces allowed between
24- * segments)</li>
25- * <li>ISO-8601 (Duration.parse): PT30M, PT12H, P1D, etc (months/years not
26- * supported by Duration)</li>
24+ * <li>Combined tokens: 1h30m, 2d12h, 1w2d3h4m5s6ms (spaces allowed between segments)</li>
25+ * <li>ISO-8601 (Duration.parse): PT30M, PT12H, P1D, etc (months/years not supported by Duration)</li>
2726 * <li>Plain number: "30" -> uses a configurable default unit</li>
2827 * </ul>
2928 */
@@ -75,6 +74,10 @@ public boolean isEmpty() {
7574 * <p>
7675 * Example: {@code parse("30", Unit.MINUTES)} => 30 minutes
7776 * </p>
77+ *
78+ * @param raw raw input string
79+ * @param defaultUnit default unit for plain numbers and unknown suffixes
80+ * @return parsed duration (never null)
7881 */
7982 public static ParsedDuration parse (String raw , Unit defaultUnit ) {
8083 if (raw == null ) {
@@ -93,8 +96,7 @@ public static ParsedDuration parse(String raw, Unit defaultUnit) {
9396 String lower = s .toLowerCase (Locale .ROOT );
9497
9598 // ISO-8601 Duration support (PT30M, PT12H, P1D, etc)
96- // Duration.parse does NOT support months/years anyway; it will throw for
97- // P1M/P1Y.
99+ // Duration.parse does NOT support months/years anyway; it will throw for P1M/P1Y.
98100 if (lower .startsWith ("p" )) {
99101 try {
100102 Duration d = Duration .parse (s .toUpperCase (Locale .ROOT ));
@@ -131,22 +133,118 @@ public static ParsedDuration parse(String raw, Unit defaultUnit) {
131133 return applySuffix (value , suffix , defaultUnit );
132134 }
133135
136+ /**
137+ * Parses the input using {@code defaultUnit} if the string is just a number.
138+ *
139+ * <p>
140+ * Example: {@code parse("30", TimeUnit.MINUTES)} => 30 minutes
141+ * </p>
142+ *
143+ * <p>
144+ * Note: {@link ParsedDuration} stores fixed milliseconds. If you use
145+ * {@link TimeUnit#MICROSECONDS} or {@link TimeUnit#NANOSECONDS} as the default unit,
146+ * the parsed value will be truncated to milliseconds (via {@link TimeUnit#toMillis(long)}).
147+ * </p>
148+ *
149+ * @param raw raw input string
150+ * @param defaultUnit default unit for plain numbers and unknown suffixes
151+ * @return parsed duration (never null)
152+ */
153+ public static ParsedDuration parse (String raw , TimeUnit defaultUnit ) {
154+ if (raw == null ) {
155+ return empty ();
156+ }
157+ String s = raw .trim ();
158+ if (s .isEmpty ()) {
159+ return empty ();
160+ }
161+
162+ // number-only -> default time unit
163+ if (PLAIN_NUMBER .matcher (s ).matches ()) {
164+ return applyDefaultTimeUnit (s , defaultUnit );
165+ }
166+
167+ String lower = s .toLowerCase (Locale .ROOT );
168+
169+ // ISO-8601 Duration support (PT30M, PT12H, P1D, etc)
170+ if (lower .startsWith ("p" )) {
171+ try {
172+ Duration d = Duration .parse (s .toUpperCase (Locale .ROOT ));
173+ return ofMillis (d .toMillis ());
174+ } catch (Exception ignored ) {
175+ // fall through
176+ }
177+ }
178+
179+ // Combined tokens parsing
180+ ParsedDuration combined = parseCombinedTokens (lower , defaultUnit );
181+ if (combined != null ) {
182+ return combined ;
183+ }
184+
185+ Matcher m = VALUE_SUFFIX .matcher (lower );
186+ if (!m .matches ()) {
187+ String digits = extractLeadingDigits (lower );
188+ if (!digits .isEmpty ()) {
189+ return applyDefaultTimeUnit (digits , defaultUnit );
190+ }
191+ return empty ();
192+ }
193+
194+ long value = safeParseLong (m .group (1 ));
195+ String suffix = m .group (2 );
196+
197+ if (value <= 0L ) {
198+ return empty ();
199+ }
200+
201+ return applySuffix (value , suffix , defaultUnit );
202+ }
203+
134204 /**
135205 * Parses the input using MINUTES as the default unit for number-only strings.
206+ *
207+ * @param raw raw input string
208+ * @return parsed duration (never null)
136209 */
137210 public static ParsedDuration parse (String raw ) {
138211 return parse (raw , Unit .MINUTES );
139212 }
140213
141214 /**
142- * If {@code raw} is just a number, returns the same value with
143- * {@code defaultUnit} applied, otherwise returns {@link #parse(String, Unit)}
144- * result.
215+ * Parses the input using {@link TimeUnit#MINUTES} as the default unit for number-only strings.
216+ *
217+ * @param raw raw input string
218+ * @return parsed duration (never null)
219+ */
220+ public static ParsedDuration parseTimeUnit (String raw ) {
221+ return parse (raw , TimeUnit .MINUTES );
222+ }
223+
224+ /**
225+ * If {@code raw} is just a number, returns the same value with {@code defaultUnit} applied,
226+ * otherwise returns {@link #parse(String, Unit)} result.
227+ *
228+ * @param raw raw input string
229+ * @param defaultUnit default unit for plain numbers and unknown suffixes
230+ * @return parsed duration (never null)
145231 */
146232 public static ParsedDuration withDefaultUnit (String raw , Unit defaultUnit ) {
147233 return parse (raw , defaultUnit );
148234 }
149235
236+ /**
237+ * If {@code raw} is just a number, returns the same value with {@code defaultUnit} applied,
238+ * otherwise returns {@link #parse(String, TimeUnit)} result.
239+ *
240+ * @param raw raw input string
241+ * @param defaultUnit default unit for plain numbers and unknown suffixes
242+ * @return parsed duration (never null)
243+ */
244+ public static ParsedDuration withDefaultUnit (String raw , TimeUnit defaultUnit ) {
245+ return parse (raw , defaultUnit );
246+ }
247+
150248 private static ParsedDuration applyDefaultUnit (String digits , Unit unit ) {
151249 Objects .requireNonNull (unit , "defaultUnit" );
152250 long value = safeParseLong (digits );
@@ -172,9 +270,19 @@ private static ParsedDuration applyDefaultUnit(String digits, Unit unit) {
172270 }
173271 }
174272
273+ private static ParsedDuration applyDefaultTimeUnit (String digits , TimeUnit unit ) {
274+ Objects .requireNonNull (unit , "defaultUnit" );
275+ long value = safeParseLong (digits );
276+ if (value <= 0L ) {
277+ return empty ();
278+ }
279+
280+ // TimeUnit.toMillis includes overflow saturation to Long.MAX_VALUE
281+ return ofMillis (unit .toMillis (value ));
282+ }
283+
175284 /**
176- * Parses strings with multiple "value+suffix" segments like "1h30m" or "2d
177- * 12h".
285+ * Parses strings with multiple "value+suffix" segments like "1h30m" or "2d 12h".
178286 *
179287 * <p>
180288 * Returns null if the input does not look like a valid combined-token duration.
@@ -222,6 +330,53 @@ private static ParsedDuration parseCombinedTokens(String lower, Unit defaultUnit
222330 return out .isEmpty () ? empty () : out ;
223331 }
224332
333+ /**
334+ * Parses strings with multiple "value+suffix" segments like "1h30m" or "2d 12h",
335+ * using {@link TimeUnit} as the default unit for number-only tokens (and unknown suffixes).
336+ *
337+ * <p>
338+ * Returns null if the input does not look like a valid combined-token duration.
339+ * </p>
340+ */
341+ private static ParsedDuration parseCombinedTokens (String lower , TimeUnit defaultUnit ) {
342+ Matcher seg = SEGMENT .matcher (lower );
343+ int count = 0 ;
344+ int pos = 0 ;
345+ long totalMillis = 0L ;
346+
347+ while (seg .find ()) {
348+ if (!onlyWhitespaceBetween (lower , pos , seg .start ())) {
349+ return null ;
350+ }
351+ pos = seg .end ();
352+ count ++;
353+
354+ long value = safeParseLong (seg .group (1 ));
355+ String suffix = seg .group (2 );
356+ if (value <= 0L ) {
357+ return null ;
358+ }
359+
360+ ParsedDuration piece = applySuffix (value , suffix , defaultUnit );
361+ if (piece == null ) {
362+ return null ;
363+ }
364+
365+ totalMillis = safeAddMillis (totalMillis , piece .millis );
366+ }
367+
368+ if (!onlyWhitespaceBetween (lower , pos , lower .length ())) {
369+ return null ;
370+ }
371+
372+ if (count < 2 ) {
373+ return null ;
374+ }
375+
376+ ParsedDuration out = ofMillis (totalMillis );
377+ return out .isEmpty () ? empty () : out ;
378+ }
379+
225380 private static boolean onlyWhitespaceBetween (String s , int from , int to ) {
226381 for (int i = from ; i < to ; i ++) {
227382 if (!Character .isWhitespace (s .charAt (i ))) {
@@ -297,9 +452,69 @@ private static ParsedDuration applySuffix(long value, String suffixRaw, Unit def
297452 return applyDefaultUnit (Long .toString (value ), defaultUnit );
298453 }
299454 }
300-
455+
456+ private static ParsedDuration applySuffix (long value , String suffixRaw , TimeUnit defaultUnit ) {
457+ String suffix = suffixRaw .toLowerCase (Locale .ROOT );
458+
459+ switch (suffix ) {
460+ case "ms" :
461+ case "msec" :
462+ case "msecs" :
463+ case "millisecond" :
464+ case "milliseconds" :
465+ return ofMillis (value );
466+
467+ case "s" :
468+ case "sec" :
469+ case "secs" :
470+ case "second" :
471+ case "seconds" :
472+ return ofMillis (safeMul (value , 1000L ));
473+
474+ case "m" :
475+ case "min" :
476+ case "mins" :
477+ case "minute" :
478+ case "minutes" :
479+ return ofMillis (safeMul (value , 60_000L ));
480+
481+ case "h" :
482+ case "hr" :
483+ case "hrs" :
484+ case "hour" :
485+ case "hours" :
486+ return ofMillis (safeMul (value , 3_600_000L ));
487+
488+ case "d" :
489+ case "day" :
490+ case "days" :
491+ return ofMillis (safeMul (value , 86_400_000L ));
492+
493+ case "w" :
494+ case "wk" :
495+ case "wks" :
496+ case "week" :
497+ case "weeks" :
498+ return ofMillis (safeMul (value , 604_800_000L ));
499+
500+ case "mo" :
501+ case "mon" :
502+ case "mons" :
503+ case "month" :
504+ case "months" :
505+ return ofMillis (safeMul (value , 30L * 86_400_000L ));
506+
507+ default :
508+ // unknown suffix -> fallback to default timeunit
509+ return applyDefaultTimeUnit (Long .toString (value ), defaultUnit );
510+ }
511+ }
512+
301513 /**
302514 * Adds this duration to the provided instant using fixed millis.
515+ *
516+ * @param base base instant
517+ * @return base + duration, or base if null/empty
303518 */
304519 public Instant addTo (Instant base ) {
305520 if (base == null || isEmpty ()) {
@@ -312,6 +527,8 @@ public Instant addTo(Instant base) {
312527 * Delay in milliseconds from now until now + this duration.
313528 *
314529 * Guaranteed to return at least 1ms when not empty.
530+ *
531+ * @return delay in milliseconds (>= 1)
315532 */
316533 public long delayMillisFromNow () {
317534 if (isEmpty ()) {
0 commit comments