001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.marshall;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.math.*;
022import java.time.*;
023import java.util.Locale;
024
025/**
026 * Supported wire formats for {@link Duration} values.
027 */
028public enum DurationFormat {
029
030   NOT_SET,
031   ISO_8601,
032   ISO_8601_WITH_DAYS,
033   NANOS,
034   MILLIS,
035   SECONDS,
036   HOCON;
037
038   private static final BigDecimal NANOS_PER_SECOND = BigDecimal.valueOf(1_000_000_000L);
039
040   /**
041    * Formats the specified duration using this format.
042    *
043    * @param value The value to format.  Can be <jk>null</jk> (returns <jk>null</jk>).
044    * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>.
045    */
046   public String format(Duration value) {
047      if (value == null)
048         return null;
049      return switch (this) {
050         case NOT_SET, ISO_8601 -> value.toString();
051         case ISO_8601_WITH_DAYS -> formatIso8601WithDays(value);
052         case NANOS -> Long.toString(value.toNanos());
053         case MILLIS -> Long.toString(value.toMillis());
054         case SECONDS -> {
055            long seconds = value.getSeconds();
056            int nanos = value.getNano();
057            yield seconds + "." + f("%09d", nanos);
058         }
059         case HOCON -> formatHocon(value);
060      };
061   }
062
063   /**
064    * Parses the specified wire value using this format.
065    *
066    * @param value The value to parse.  Can be <jk>null</jk> or blank (returns <jk>null</jk>).
067    * @return The parsed duration, or <jk>null</jk> if {@code value} is <jk>null</jk> or blank.
068    */
069   public Duration parse(String value) {
070      if (value == null)
071         return null;
072      String s = value.trim();
073      if (s.isEmpty())
074         return null;
075      return switch (this) {
076         case NOT_SET, ISO_8601, ISO_8601_WITH_DAYS -> Duration.parse(normalizeIso(s));
077         case NANOS -> Duration.ofNanos(Long.parseLong(s));
078         case MILLIS -> Duration.ofMillis(Long.parseLong(s));
079         case SECONDS -> parseSeconds(s);
080         case HOCON -> parseHocon(s);
081      };
082   }
083
084   /**
085    * Returns <jk>true</jk> if this format is numeric on the wire.
086    *
087    * @return <jk>true</jk> if this format emits numeric wire values.
088    */
089   public boolean isNumeric() {
090      return this == NANOS || this == MILLIS || this == SECONDS;
091   }
092
093   private static String normalizeIso(String s) {
094      String x = s;
095      if (x.startsWith("PT-"))
096         x = "-PT" + x.substring(3);
097      return x;
098   }
099
100   private static Duration parseSeconds(String s) {
101      var bd = new BigDecimal(s);
102      long seconds = bd.longValue();
103      BigDecimal frac = bd.subtract(BigDecimal.valueOf(seconds));
104      int nanos = frac.multiply(NANOS_PER_SECOND).intValue();
105      return Duration.ofSeconds(seconds, nanos);
106   }
107
108   @SuppressWarnings({
109      "java:S3776" // Cognitive complexity acceptable for ISO-8601 duration formatting logic
110   })
111   private static String formatIso8601WithDays(Duration value) {
112      boolean neg = value.isNegative();
113      Duration d = neg ? value.abs() : value;
114      long totalSeconds = d.getSeconds();
115      int nanos = d.getNano();
116      long days = totalSeconds / 86400;
117      long rem = totalSeconds % 86400;
118      long hours = rem / 3600;
119      rem %= 3600;
120      long minutes = rem / 60;
121      long seconds = rem % 60;
122
123      var sb = new StringBuilder();
124      if (neg)
125         sb.append('-');
126      sb.append('P');
127      if (days > 0)
128         sb.append(days).append('D');
129      if (hours > 0 || minutes > 0 || seconds > 0 || nanos > 0 || days == 0) {
130         sb.append('T');
131         if (hours > 0)
132            sb.append(hours).append('H');
133         if (minutes > 0)
134            sb.append(minutes).append('M');
135         if (seconds > 0 || nanos > 0 || (hours == 0 && minutes == 0))
136            sb.append(seconds).append(nanos == 0 ? "" : "." + trimTrailingZeros(f("%09d", nanos))).append('S');
137      }
138      return sb.toString();
139   }
140
141   private static String formatHocon(Duration value) {
142      long nanos = value.toNanos();
143      long abs = Math.abs(nanos);
144      if (abs == 0)
145         return "0s";
146      long sign = nanos < 0 ? -1 : 1;
147      long[] divisors = {86_400_000_000_000L, 3_600_000_000_000L, 60_000_000_000L, 1_000_000_000L, 1_000_000L, 1_000L, 1L};
148      String[] suffixes = {"d", "h", "m", "s", "ms", "us", "ns"};
149      for (int i = 0; i < divisors.length; i++) {
150         long divisor = divisors[i];
151         if (abs % divisor == 0)
152            return (sign * (abs / divisor)) + suffixes[i];
153      }
154      return nanos + "ns";
155   }
156
157   private static Duration parseHocon(String s) {
158      // Programmatic equivalent of full match ^([+-]?\d++(?:\.\d++)?)(ns|us|ms|s|m|h|d)$ (CASE_INSENSITIVE) to avoid per-parse matching.
159      // Group 1 = the numeric part, group 2 = the (case-insensitive) unit suffix. \d = ASCII [0-9].
160      var len = s.length();
161      var i = 0;
162      if (i < len && (s.charAt(i) == '+' || s.charAt(i) == '-'))
163         i++;
164      var digitsStart = i;
165      while (i < len && isAsciiDigit(s.charAt(i)))
166         i++;
167      var valid = i > digitsStart;
168      if (valid && i < len && s.charAt(i) == '.') {
169         i++;
170         var fracStart = i;
171         while (i < len && isAsciiDigit(s.charAt(i)))
172            i++;
173         valid = i > fracStart;
174      }
175      BigDecimal nanos = null;
176      if (valid) {
177         var value = new BigDecimal(s.substring(0, i));
178         nanos = switch (s.substring(i).toLowerCase(Locale.ROOT)) {
179            case "d" -> value.multiply(BigDecimal.valueOf(86_400_000_000_000L));
180            case "h" -> value.multiply(BigDecimal.valueOf(3_600_000_000_000L));
181            case "m" -> value.multiply(BigDecimal.valueOf(60_000_000_000L));
182            case "s" -> value.multiply(BigDecimal.valueOf(1_000_000_000L));
183            case "ms" -> value.multiply(BigDecimal.valueOf(1_000_000L));
184            case "us" -> value.multiply(BigDecimal.valueOf(1_000L));
185            case "ns" -> value;
186            default -> null;
187         };
188      }
189      if (nanos == null)
190         throw iaex("Invalid HOCON duration: %s", s);
191      return Duration.ofNanos(nanos.longValue());
192   }
193
194   private static boolean isAsciiDigit(char c) {
195      return c >= '0' && c <= '9';
196   }
197
198   // Removes trailing '0' characters. Equivalent of replaceAll("0++$", ""); returns "" if the input is all zeros.
199   private static String trimTrailingZeros(String s) {
200      var end = s.length();
201      while (end > 0 && s.charAt(end - 1) == '0')
202         end--;
203      return s.substring(0, end);
204   }
205}