1414
1515package dev .cel .common .internal ;
1616
17+ import static dev .cel .common .internal .ProtoTimeUtils .DURATION_SECONDS_MAX ;
18+ import static dev .cel .common .internal .ProtoTimeUtils .DURATION_SECONDS_MIN ;
19+ import static dev .cel .common .internal .ProtoTimeUtils .MAX_NANOS ;
20+ import static dev .cel .common .internal .ProtoTimeUtils .NANOS_PER_SECOND ;
21+ import static dev .cel .common .internal .ProtoTimeUtils .TIMESTAMP_SECONDS_MAX ;
22+ import static dev .cel .common .internal .ProtoTimeUtils .TIMESTAMP_SECONDS_MIN ;
23+
1724import com .google .common .base .Strings ;
25+ import com .google .errorprone .annotations .CanIgnoreReturnValue ;
1826import com .google .protobuf .Timestamp ;
1927import dev .cel .common .annotations .Internal ;
2028import dev .cel .common .exceptions .CelBadFormatException ;
3240public final class DateTimeHelpers {
3341 public static final String UTC = "UTC" ;
3442
35- // Timestamp for "0001-01-01T00:00:00Z"
36- private static final long TIMESTAMP_SECONDS_MIN = -62135596800L ;
37- // Timestamp for "9999-12-31T23:59:59Z"
38- private static final long TIMESTAMP_SECONDS_MAX = 253402300799L ;
39-
40- private static final long DURATION_SECONDS_MIN = -315576000000L ;
41- private static final long DURATION_SECONDS_MAX = 315576000000L ;
42- private static final int NANOS_PER_SECOND = 1000000000 ;
43+ private static final Duration DURATION_MIN = Duration .ofSeconds (DURATION_SECONDS_MIN , -MAX_NANOS );
44+ private static final Duration DURATION_MAX = Duration .ofSeconds (DURATION_SECONDS_MAX , MAX_NANOS );
4345
4446 /**
4547 * Constructs a new {@link LocalDateTime} instance
@@ -79,6 +81,14 @@ public static LocalDateTime newLocalDateTime(Instant instant, String tz) {
7981 return instant .atZone (timeZone (tz )).toLocalDateTime ();
8082 }
8183
84+ /** Creates an {@link Instant} from epoch seconds, validating CEL timestamp bounds. */
85+ // CEL's timestamp(int) conversion accepts epoch seconds as an int64.
86+ @ SuppressWarnings ("GoodTime-ApiWithNumericTimeUnit" )
87+ public static Instant fromSecondsToInstant (long seconds ) {
88+ checkSecondsValid (seconds );
89+ return Instant .ofEpochSecond (seconds );
90+ }
91+
8292 /**
8393 * Parse from RFC 3339 date string to {@link java.time.Instant}.
8494 *
@@ -92,6 +102,75 @@ public static Instant parse(String text) {
92102 return instant ;
93103 }
94104
105+ /** Parses a CEL duration string (following Go {@code time.ParseDuration} syntax). */
106+ public static Duration parseDuration (String text ) {
107+ if (text .isEmpty ()) {
108+ throw invalidDurationFormat ();
109+ }
110+ int len = text .length ();
111+ int i = 0 ;
112+ char first = text .charAt (0 );
113+ boolean negative = (first == '-' );
114+ if (negative || first == '+' ) {
115+ i ++;
116+ }
117+ if (i == len ) {
118+ throw invalidDurationFormat ();
119+ }
120+ if (len - i == 1 && text .charAt (i ) == '0' ) {
121+ return Duration .ZERO ;
122+ }
123+ long totalSeconds = 0 ;
124+ long totalNanos = 0 ;
125+ while (i < len ) {
126+ int intStart = i ;
127+ int intEnd = scanAsciiDigits (text , intStart );
128+ int fracStart = intEnd ;
129+ int fracEnd = intEnd ;
130+ if (intEnd < len && text .charAt (intEnd ) == '.' ) {
131+ fracStart = intEnd + 1 ;
132+ fracEnd = scanAsciiDigits (text , fracStart );
133+ }
134+ if (intStart == intEnd && fracStart == fracEnd ) {
135+ throw invalidDurationFormat ();
136+ }
137+ i = fracEnd ;
138+ int unitStart = i ;
139+ while (i < len && text .charAt (i ) != '.' && !isAsciiDigit (text .charAt (i ))) {
140+ i ++;
141+ }
142+ long unitNanos = parseUnitNanos (text , unitStart , i );
143+
144+ long segSeconds = 0 ;
145+ long segNanos = 0 ;
146+ for (int p = intStart ; p < intEnd ; p ++) {
147+ int digit = Character .digit (text .charAt (p ), 10 );
148+ segSeconds *= 10 ;
149+ segNanos = segNanos * 10 + digit * unitNanos ;
150+ segSeconds += segNanos / NANOS_PER_SECOND ;
151+ segNanos %= NANOS_PER_SECOND ;
152+ if (segSeconds > DURATION_SECONDS_MAX ) {
153+ throw outOfRangeDuration (text );
154+ }
155+ }
156+
157+ long fracNanos = 0 ;
158+ for (int p = fracEnd - 1 ; p >= fracStart ; p --) {
159+ fracNanos = (Character .digit (text .charAt (p ), 10 ) * unitNanos + fracNanos ) / 10 ;
160+ }
161+
162+ totalNanos += segNanos + fracNanos ;
163+ totalSeconds += segSeconds + totalNanos / NANOS_PER_SECOND ;
164+ totalNanos %= NANOS_PER_SECOND ;
165+ if (totalSeconds > DURATION_SECONDS_MAX ) {
166+ throw outOfRangeDuration (text );
167+ }
168+ }
169+ return negative
170+ ? Duration .ofSeconds (-totalSeconds , -totalNanos )
171+ : Duration .ofSeconds (totalSeconds , totalNanos );
172+ }
173+
95174 /** Adds a duration to an instant. */
96175 public static Instant add (Instant ts , Duration dur ) {
97176 Instant newInstant = ts .plus (dur );
@@ -102,10 +181,7 @@ public static Instant add(Instant ts, Duration dur) {
102181
103182 /** Adds two durations */
104183 public static Duration add (Duration d1 , Duration d2 ) {
105- Duration newDuration = d1 .plus (d2 );
106- checkValid (newDuration );
107-
108- return newDuration ;
184+ return checkValid (d1 .plus (d2 ));
109185 }
110186
111187 /** Subtracts a duration to an instant. */
@@ -118,54 +194,72 @@ public static Instant subtract(Instant ts, Duration dur) {
118194
119195 /** Subtract a duration from another. */
120196 public static Duration subtract (Duration d1 , Duration d2 ) {
121- Duration newDuration = d1 .minus (d2 );
122- checkValid ( newDuration );
197+ return checkValid ( d1 .minus (d2 ) );
198+ }
123199
124- return newDuration ;
200+ /** Calculates the difference between two instants. */
201+ public static Duration between (Instant d1 , Instant d2 ) {
202+ return checkValid (Duration .between (d1 , d2 ));
125203 }
126204
127- /**
128- * Formats a {@link Duration} into a minimal seconds-based representation.
129- *
130- * <p>Note: follows {@code ProtoTimeUtils#toString(Duration)} implementation
131- */
205+ /** Formats a {@link Duration} into a minimal seconds-based representation. */
132206 public static String toString (Duration duration ) {
133- if (duration .isZero ()) {
134- return "0s" ;
207+ long seconds = duration .getSeconds ();
208+ int nanos = duration .getNano ();
209+ if (seconds < 0 && nanos > 0 ) {
210+ seconds ++;
211+ nanos -= NANOS_PER_SECOND ;
135212 }
213+ return ProtoTimeUtils .formatDuration (seconds , nanos );
214+ }
136215
137- long totalNanos = duration .toNanos ();
138- StringBuilder sb = new StringBuilder ();
139-
140- if (totalNanos < 0 ) {
141- sb .append ('-' );
142- totalNanos = -totalNanos ;
216+ private static int scanAsciiDigits (String text , int i ) {
217+ while (i < text .length () && isAsciiDigit (text .charAt (i ))) {
218+ i ++;
143219 }
220+ return i ;
221+ }
222+
223+ private static boolean isAsciiDigit (char c ) {
224+ return c >= '0' && c <= '9' ;
225+ }
144226
145- long seconds = totalNanos / 1_000_000_000 ;
146- int nanos = (int ) (totalNanos % 1_000_000_000 );
147-
148- sb .append (seconds );
149-
150- // Follows ProtoTimeUtils.toString(Duration) implementation
151- if (nanos > 0 ) {
152- sb .append ('.' );
153- if (nanos % 1_000_000 == 0 ) {
154- // Millisecond precision (3 digits)
155- int millis = nanos / 1_000_000 ;
156- sb .append (String .format (Locale .US , "%03d" , millis ));
157- } else if (nanos % 1_000 == 0 ) {
158- // Microsecond precision (6 digits)
159- int micros = nanos / 1_000 ;
160- sb .append (String .format (Locale .US , "%06d" , micros ));
161- } else {
162- // Nanosecond precision (9 digits)
163- sb .append (String .format (Locale .US , "%09d" , nanos ));
227+ private static long parseUnitNanos (String text , int start , int end ) {
228+ int len = end - start ;
229+ if (len == 1 ) {
230+ switch (text .charAt (start )) {
231+ case 's' :
232+ return NANOS_PER_SECOND ;
233+ case 'm' :
234+ return 60L * NANOS_PER_SECOND ;
235+ case 'h' :
236+ return 3600L * NANOS_PER_SECOND ;
237+ default :
238+ break ;
239+ }
240+ } else if (len == 2 && text .charAt (start + 1 ) == 's' ) {
241+ switch (text .charAt (start )) {
242+ case 'n' :
243+ return 1L ;
244+ case 'u' :
245+ case '\u00b5' :
246+ case '\u03bc' :
247+ return 1_000L ;
248+ case 'm' :
249+ return 1_000_000L ;
250+ default :
251+ break ;
164252 }
165253 }
254+ throw invalidDurationFormat ();
255+ }
256+
257+ private static IllegalArgumentException invalidDurationFormat () {
258+ return new IllegalArgumentException ("invalid duration format" );
259+ }
166260
167- sb . append ( 's' );
168- return sb . toString ( );
261+ private static IllegalArgumentException outOfRangeDuration ( String text ) {
262+ return new IllegalArgumentException ( "Duration is out of valid range: " + text );
169263 }
170264
171265 /**
@@ -205,55 +299,29 @@ private static ZoneId timeZone(String tz) {
205299
206300 /** Throws an {@link IllegalArgumentException} if the given {@link Timestamp} is not valid. */
207301 public static void checkValid (Instant instant ) {
208- long seconds = instant .getEpochSecond ();
302+ checkSecondsValid (instant .getEpochSecond ());
303+ }
209304
210- if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX ) {
305+ @ CanIgnoreReturnValue
306+ private static Duration checkValid (Duration duration ) {
307+ if (duration .compareTo (DURATION_MIN ) < 0 || duration .compareTo (DURATION_MAX ) > 0 ) {
211308 throw new IllegalArgumentException (
212309 Strings .lenientFormat (
213- "Timestamp is not valid. "
214- + "Seconds (%s) must be in range [-62,135,596,800, +253,402,300,799]. "
215- + "Nanos (%s) must be in range [0, +999,999,999]." ,
216- seconds , instant .getNano ()));
310+ "Duration is out of valid range [-315,576,000,000.999999999s,"
311+ + " +315,576,000,000.999999999s]: %s" ,
312+ duration ));
217313 }
314+ return duration ;
218315 }
219316
220- /** Throws an {@link IllegalArgumentException} if the given {@link Duration} is not valid. */
221- private static void checkValid (Duration duration ) {
222- long seconds = duration .getSeconds ();
223- int nanos = duration .getNano ();
224- if (!isDurationValid (seconds , nanos )) {
317+ private static void checkSecondsValid (long seconds ) {
318+ if (seconds < TIMESTAMP_SECONDS_MIN || seconds > TIMESTAMP_SECONDS_MAX ) {
225319 throw new IllegalArgumentException (
226320 Strings .lenientFormat (
227- "Duration is not valid. "
228- + "Seconds (%s) must be in range [-315,576,000,000, +315,576,000,000]. "
229- + "Nanos (%s) must be in range [-999,999,999, +999,999,999]. "
230- + "Nanos must have the same sign as seconds" ,
231- seconds , nanos ));
232- }
233- }
234-
235- /**
236- * Returns true if the given number of seconds and nanos is a valid {@link Duration}. The {@code
237- * seconds} value must be in the range [-315,576,000,000, +315,576,000,000]. The {@code nanos}
238- * value must be in the range [-999,999,999, +999,999,999].
239- *
240- * <p><b>Note:</b> Durations less than one second are represented with a 0 {@code seconds} field
241- * and a positive or negative {@code nanos} field. For durations of one second or more, a non-zero
242- * value for the {@code nanos} field must be of the same sign as the {@code seconds} field.
243- */
244- private static boolean isDurationValid (long seconds , int nanos ) {
245- if (seconds < DURATION_SECONDS_MIN || seconds > DURATION_SECONDS_MAX ) {
246- return false ;
247- }
248- if (nanos < -999999999L || nanos >= NANOS_PER_SECOND ) {
249- return false ;
250- }
251- if (seconds < 0 || nanos < 0 ) {
252- if (seconds > 0 || nanos > 0 ) {
253- return false ;
254- }
321+ "Timestamp is not valid. Seconds (%s) must be in range [-62,135,596,800,"
322+ + " +253,402,300,799]." ,
323+ seconds ));
255324 }
256- return true ;
257325 }
258326
259327 private DateTimeHelpers () {}
0 commit comments