Skip to content

Commit 6712306

Browse files
committed
Add ParsedDuration class for flexible duration parsing and handling
1 parent 58176a7 commit 6712306

File tree

2 files changed

+585
-0
lines changed

2 files changed

+585
-0
lines changed
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
package com.bencodez.simpleapi.time;
2+
3+
import java.time.Duration;
4+
import java.util.Locale;
5+
import java.util.Objects;
6+
import java.util.regex.Matcher;
7+
import java.util.regex.Pattern;
8+
9+
/**
10+
* Parsed duration that preserves calendar-month units separately from fixed millis.
11+
*
12+
* <p>
13+
* Supported formats (case-insensitive):
14+
* <ul>
15+
* <li>5000ms</li>
16+
* <li>60s</li>
17+
* <li>30m</li>
18+
* <li>12h</li>
19+
* <li>1d</li>
20+
* <li>2w</li>
21+
* <li>1mo (calendar months)</li>
22+
* <li>Combined tokens: 1h30m, 2d12h, 1w2d3h4m5s6ms, 1mo2d (spaces allowed between segments)</li>
23+
* <li>ISO-8601 (Duration.parse): PT30M, PT12H, P1D, etc (months/years not supported)</li>
24+
* <li>Plain number: "30" -> uses a configurable default unit</li>
25+
* </ul>
26+
*
27+
* <p>
28+
* Notes:
29+
* <ul>
30+
* <li>Calendar months are stored in {@link #months} and are NOT converted to millis.</li>
31+
* <li>For ISO strings, {@code P1M} is NOT accepted (ambiguous vs minutes).</li>
32+
* </ul>
33+
*/
34+
public final class ParsedDuration {
35+
36+
public enum Unit {
37+
MS("ms"),
38+
SECONDS("s"),
39+
MINUTES("m"),
40+
HOURS("h"),
41+
DAYS("d"),
42+
WEEKS("w"),
43+
MONTHS("mo");
44+
45+
private final String suffix;
46+
47+
Unit(String suffix) {
48+
this.suffix = suffix;
49+
}
50+
51+
public String getSuffix() {
52+
return suffix;
53+
}
54+
}
55+
56+
private static final Pattern PLAIN_NUMBER = Pattern.compile("^[0-9]+$");
57+
private static final Pattern VALUE_SUFFIX = Pattern.compile("^([0-9]+)\\s*([a-zA-Z]+)$");
58+
private static final Pattern SEGMENT = Pattern.compile("([0-9]+)\\s*([a-zA-Z]+)");
59+
60+
private final long millis;
61+
private final int months;
62+
63+
private ParsedDuration(long millis, int months) {
64+
this.millis = Math.max(0L, millis);
65+
this.months = Math.max(0, months);
66+
}
67+
68+
public static ParsedDuration empty() {
69+
return new ParsedDuration(0L, 0);
70+
}
71+
72+
public static ParsedDuration ofMillis(long millis) {
73+
return new ParsedDuration(millis, 0);
74+
}
75+
76+
public static ParsedDuration ofMonths(int months) {
77+
return new ParsedDuration(0L, months);
78+
}
79+
80+
public static ParsedDuration of(long millis, int months) {
81+
return new ParsedDuration(millis, months);
82+
}
83+
84+
public long getMillis() {
85+
return millis;
86+
}
87+
88+
public int getMonths() {
89+
return months;
90+
}
91+
92+
public boolean isEmpty() {
93+
return millis <= 0L && months <= 0;
94+
}
95+
96+
/**
97+
* Parses the input using {@code defaultUnit} if the string is just a number.
98+
*
99+
* <p>Example: {@code parse("30", Unit.MINUTES)} => 30 minutes</p>
100+
*/
101+
public static ParsedDuration parse(String raw, Unit defaultUnit) {
102+
if (raw == null) {
103+
return empty();
104+
}
105+
String s = raw.trim();
106+
if (s.isEmpty()) {
107+
return empty();
108+
}
109+
110+
// number-only -> default unit
111+
if (PLAIN_NUMBER.matcher(s).matches()) {
112+
return applyDefaultUnit(s, defaultUnit);
113+
}
114+
115+
String lower = s.toLowerCase(Locale.ROOT);
116+
117+
// ISO-8601 Duration support (PT30M, PT12H, P1D, etc)
118+
// We explicitly reject strings that look like "P...M" without "T" (month ambiguous)
119+
if (lower.startsWith("p")) {
120+
// reject potential month/year ISO (P1M, P2Y, etc)
121+
// allow PnD / PTnH / PTnM / PTnS etc, but not months/years.
122+
if (looksLikeIsoWithMonthsOrYears(lower)) {
123+
// Let it fall through to suffix parsing if it's like "1mo" (not ISO), otherwise empty.
124+
} else {
125+
try {
126+
Duration d = Duration.parse(s.toUpperCase(Locale.ROOT));
127+
return ofMillis(d.toMillis());
128+
} catch (Exception ignored) {
129+
// fall through
130+
}
131+
}
132+
}
133+
134+
// Try combined token parsing first: "1h30m", "2d 12h", "1w2d3h", etc.
135+
ParsedDuration combined = parseCombinedTokens(lower, defaultUnit);
136+
if (combined != null) {
137+
return combined;
138+
}
139+
140+
// parse single suffix form: "10m", "5000ms", "1mo"
141+
Matcher m = VALUE_SUFFIX.matcher(lower);
142+
if (!m.matches()) {
143+
// unknown -> best-effort fallback: try extract number, apply default unit
144+
String digits = extractLeadingDigits(lower);
145+
if (!digits.isEmpty()) {
146+
return applyDefaultUnit(digits, defaultUnit);
147+
}
148+
return empty();
149+
}
150+
151+
long value = safeParseLong(m.group(1));
152+
String suffix = m.group(2);
153+
154+
if (value <= 0L) {
155+
return empty();
156+
}
157+
158+
return applySuffix(value, suffix, defaultUnit);
159+
}
160+
161+
/**
162+
* Parses the input using MINUTES as the default unit for number-only strings.
163+
*/
164+
public static ParsedDuration parse(String raw) {
165+
return parse(raw, Unit.MINUTES);
166+
}
167+
168+
/**
169+
* If {@code raw} is just a number, returns the same value with {@code defaultUnit} applied,
170+
* otherwise returns {@link #parse(String, Unit)} result.
171+
*
172+
* <p>This is the "set default unit value if it's just a number" helper.</p>
173+
*
174+
* <p>Example: {@code ParsedDuration.withDefaultUnit("30", Unit.SECONDS)} => 30s</p>
175+
*/
176+
public static ParsedDuration withDefaultUnit(String raw, Unit defaultUnit) {
177+
return parse(raw, defaultUnit);
178+
}
179+
180+
private static ParsedDuration applyDefaultUnit(String digits, Unit unit) {
181+
Objects.requireNonNull(unit, "defaultUnit");
182+
long value = safeParseLong(digits);
183+
if (value <= 0L) {
184+
return empty();
185+
}
186+
187+
switch (unit) {
188+
case MS:
189+
return ofMillis(value);
190+
case SECONDS:
191+
return ofMillis(value * 1000L);
192+
case MINUTES:
193+
return ofMillis(value * 60_000L);
194+
case HOURS:
195+
return ofMillis(value * 3_600_000L);
196+
case DAYS:
197+
return ofMillis(value * 86_400_000L);
198+
case WEEKS:
199+
return ofMillis(value * 604_800_000L);
200+
case MONTHS:
201+
return ofMonths((int) Math.min(Integer.MAX_VALUE, value));
202+
default:
203+
return empty();
204+
}
205+
}
206+
207+
/**
208+
* Parses strings with multiple "value+suffix" segments like "1h30m" or "2d 12h".
209+
*
210+
* <p>Returns null if the input does not look like a valid combined-token duration.</p>
211+
*/
212+
private static ParsedDuration parseCombinedTokens(String lower, Unit defaultUnit) {
213+
// Combined tokens must have at least two segments.
214+
Matcher seg = SEGMENT.matcher(lower);
215+
int count = 0;
216+
int pos = 0;
217+
long totalMillis = 0L;
218+
int totalMonths = 0;
219+
220+
while (seg.find()) {
221+
// Ensure we only skip whitespace between segments.
222+
if (!onlyWhitespaceBetween(lower, pos, seg.start())) {
223+
return null;
224+
}
225+
pos = seg.end();
226+
count++;
227+
228+
long value = safeParseLong(seg.group(1));
229+
String suffix = seg.group(2);
230+
if (value <= 0L) {
231+
return null;
232+
}
233+
234+
ParsedDuration piece = applySuffix(value, suffix, defaultUnit);
235+
if (piece == null) {
236+
return null;
237+
}
238+
239+
totalMillis = safeAddMillis(totalMillis, piece.millis);
240+
totalMonths = safeAddMonths(totalMonths, piece.months);
241+
}
242+
243+
// trailing non-whitespace means it's not a valid combined token string
244+
if (!onlyWhitespaceBetween(lower, pos, lower.length())) {
245+
return null;
246+
}
247+
248+
// Must be at least 2 segments to count as combined tokens.
249+
if (count < 2) {
250+
return null;
251+
}
252+
253+
ParsedDuration out = of(totalMillis, totalMonths);
254+
return out.isEmpty() ? empty() : out;
255+
}
256+
257+
private static boolean onlyWhitespaceBetween(String s, int from, int to) {
258+
for (int i = from; i < to; i++) {
259+
if (!Character.isWhitespace(s.charAt(i))) {
260+
return false;
261+
}
262+
}
263+
return true;
264+
}
265+
266+
private static long safeAddMillis(long a, long b) {
267+
long r = a + b;
268+
if (((a ^ r) & (b ^ r)) < 0) {
269+
return Long.MAX_VALUE;
270+
}
271+
return r;
272+
}
273+
274+
private static int safeAddMonths(int a, int b) {
275+
long r = (long) a + (long) b;
276+
if (r > Integer.MAX_VALUE) {
277+
return Integer.MAX_VALUE;
278+
}
279+
return (int) r;
280+
}
281+
282+
private static ParsedDuration applySuffix(long value, String suffixRaw, Unit defaultUnit) {
283+
String suffix = suffixRaw.toLowerCase(Locale.ROOT);
284+
285+
switch (suffix) {
286+
case "ms":
287+
case "msec":
288+
case "msecs":
289+
case "millisecond":
290+
case "milliseconds":
291+
return ofMillis(value);
292+
case "s":
293+
case "sec":
294+
case "secs":
295+
case "second":
296+
case "seconds":
297+
return ofMillis(safeMul(value, 1000L));
298+
case "m":
299+
case "min":
300+
case "mins":
301+
case "minute":
302+
case "minutes":
303+
return ofMillis(safeMul(value, 60_000L));
304+
case "h":
305+
case "hr":
306+
case "hrs":
307+
case "hour":
308+
case "hours":
309+
return ofMillis(safeMul(value, 3_600_000L));
310+
case "d":
311+
case "day":
312+
case "days":
313+
return ofMillis(safeMul(value, 86_400_000L));
314+
case "w":
315+
case "wk":
316+
case "wks":
317+
case "week":
318+
case "weeks":
319+
return ofMillis(safeMul(value, 604_800_000L));
320+
case "mo":
321+
case "mon":
322+
case "mons":
323+
case "month":
324+
case "months":
325+
return ofMonths((int) Math.min(Integer.MAX_VALUE, value));
326+
default:
327+
// unknown suffix -> fallback to default unit
328+
return applyDefaultUnit(Long.toString(value), defaultUnit);
329+
}
330+
}
331+
332+
private static long safeMul(long a, long b) {
333+
if (a == 0L || b == 0L) {
334+
return 0L;
335+
}
336+
if (a > Long.MAX_VALUE / b) {
337+
return Long.MAX_VALUE;
338+
}
339+
return a * b;
340+
}
341+
342+
private static boolean looksLikeIsoWithMonthsOrYears(String lower) {
343+
if (!lower.startsWith("p")) {
344+
return false;
345+
}
346+
boolean hasT = lower.indexOf('t') >= 0;
347+
if (hasT) {
348+
return false;
349+
}
350+
return lower.indexOf('m') >= 0 || lower.indexOf('y') >= 0;
351+
}
352+
353+
private static String extractLeadingDigits(String s) {
354+
int i = 0;
355+
while (i < s.length() && Character.isDigit(s.charAt(i))) {
356+
i++;
357+
}
358+
return i > 0 ? s.substring(0, i) : "";
359+
}
360+
361+
private static long safeParseLong(String s) {
362+
try {
363+
return Long.parseLong(s);
364+
} catch (Exception e) {
365+
return 0L;
366+
}
367+
}
368+
369+
@Override
370+
public String toString() {
371+
if (isEmpty()) {
372+
return "ParsedDuration{empty}";
373+
}
374+
return "ParsedDuration{millis=" + millis + ", months=" + months + "}";
375+
}
376+
}

0 commit comments

Comments
 (0)