duration.go raw

   1  package duration
   2  
   3  import (
   4  	"encoding/json"
   5  	"errors"
   6  	"fmt"
   7  	"math"
   8  	"strconv"
   9  	"strings"
  10  	"time"
  11  	"unicode"
  12  )
  13  
  14  // Duration holds all the smaller units that make up the duration
  15  type Duration struct {
  16  	Years    float64
  17  	Months   float64
  18  	Weeks    float64
  19  	Days     float64
  20  	Hours    float64
  21  	Minutes  float64
  22  	Seconds  float64
  23  	Negative bool
  24  }
  25  
  26  const (
  27  	parsingPeriod = iota
  28  	parsingTime
  29  
  30  	hoursPerDay   = 24
  31  	hoursPerWeek  = hoursPerDay * 7
  32  	hoursPerMonth = hoursPerYear / 12
  33  	hoursPerYear  = hoursPerDay * 365
  34  
  35  	nsPerSecond = 1000000000
  36  	nsPerMinute = nsPerSecond * 60
  37  	nsPerHour   = nsPerMinute * 60
  38  	nsPerDay    = nsPerHour * hoursPerDay
  39  	nsPerWeek   = nsPerHour * hoursPerWeek
  40  	nsPerMonth  = nsPerHour * hoursPerMonth
  41  	nsPerYear   = nsPerHour * hoursPerYear
  42  )
  43  
  44  var (
  45  	// ErrUnexpectedInput is returned when an input in the duration string does not match expectations
  46  	ErrUnexpectedInput = errors.New("unexpected input")
  47  )
  48  
  49  // Parse attempts to parse the given duration string into a *Duration,
  50  // if parsing fails an error is returned instead.
  51  func Parse(d string) (*Duration, error) {
  52  	state := parsingPeriod
  53  	duration := &Duration{}
  54  	num := ""
  55  	var err error
  56  
  57  	switch {
  58  	case strings.HasPrefix(d, "P"): // standard duration
  59  	case strings.HasPrefix(d, "-P"): // negative duration
  60  		duration.Negative = true
  61  		d = strings.TrimPrefix(d, "-") // remove the negative sign
  62  	default:
  63  		return nil, ErrUnexpectedInput
  64  	}
  65  
  66  	for _, char := range d {
  67  		switch char {
  68  		case 'P':
  69  			if state != parsingPeriod {
  70  				return nil, ErrUnexpectedInput
  71  			}
  72  		case 'T':
  73  			state = parsingTime
  74  		case 'Y':
  75  			if state != parsingPeriod {
  76  				return nil, ErrUnexpectedInput
  77  			}
  78  
  79  			duration.Years, err = strconv.ParseFloat(num, 64)
  80  			if err != nil {
  81  				return nil, err
  82  			}
  83  			num = ""
  84  		case 'M':
  85  			if state == parsingPeriod {
  86  				duration.Months, err = strconv.ParseFloat(num, 64)
  87  				if err != nil {
  88  					return nil, err
  89  				}
  90  				num = ""
  91  			} else if state == parsingTime {
  92  				duration.Minutes, err = strconv.ParseFloat(num, 64)
  93  				if err != nil {
  94  					return nil, err
  95  				}
  96  				num = ""
  97  			}
  98  		case 'W':
  99  			if state != parsingPeriod {
 100  				return nil, ErrUnexpectedInput
 101  			}
 102  
 103  			duration.Weeks, err = strconv.ParseFloat(num, 64)
 104  			if err != nil {
 105  				return nil, err
 106  			}
 107  			num = ""
 108  		case 'D':
 109  			if state != parsingPeriod {
 110  				return nil, ErrUnexpectedInput
 111  			}
 112  
 113  			duration.Days, err = strconv.ParseFloat(num, 64)
 114  			if err != nil {
 115  				return nil, err
 116  			}
 117  			num = ""
 118  		case 'H':
 119  			if state != parsingTime {
 120  				return nil, ErrUnexpectedInput
 121  			}
 122  
 123  			duration.Hours, err = strconv.ParseFloat(num, 64)
 124  			if err != nil {
 125  				return nil, err
 126  			}
 127  			num = ""
 128  		case 'S':
 129  			if state != parsingTime {
 130  				return nil, ErrUnexpectedInput
 131  			}
 132  
 133  			duration.Seconds, err = strconv.ParseFloat(num, 64)
 134  			if err != nil {
 135  				return nil, err
 136  			}
 137  			num = ""
 138  		default:
 139  			if unicode.IsNumber(char) || char == '.' {
 140  				num += string(char)
 141  				continue
 142  			}
 143  
 144  			return nil, ErrUnexpectedInput
 145  		}
 146  	}
 147  
 148  	return duration, nil
 149  }
 150  
 151  // FromTimeDuration converts the given time.Duration into duration.Duration.
 152  // Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
 153  // since obviously those things vary month to month and year to year.
 154  func FromTimeDuration(d time.Duration) *Duration {
 155  	duration := &Duration{}
 156  	if d == 0 {
 157  		return duration
 158  	}
 159  
 160  	if d < 0 {
 161  		d = -d
 162  		duration.Negative = true
 163  	}
 164  
 165  	if d.Hours() >= hoursPerYear {
 166  		duration.Years = math.Floor(d.Hours() / hoursPerYear)
 167  		d -= time.Duration(duration.Years) * nsPerYear
 168  	}
 169  	if d.Hours() >= hoursPerMonth {
 170  		duration.Months = math.Floor(d.Hours() / hoursPerMonth)
 171  		d -= time.Duration(duration.Months) * nsPerMonth
 172  	}
 173  	if d.Hours() >= hoursPerWeek {
 174  		duration.Weeks = math.Floor(d.Hours() / hoursPerWeek)
 175  		d -= time.Duration(duration.Weeks) * nsPerWeek
 176  	}
 177  	if d.Hours() >= hoursPerDay {
 178  		duration.Days = math.Floor(d.Hours() / hoursPerDay)
 179  		d -= time.Duration(duration.Days) * nsPerDay
 180  	}
 181  	if d.Hours() >= 1 {
 182  		duration.Hours = math.Floor(d.Hours())
 183  		d -= time.Duration(duration.Hours) * nsPerHour
 184  	}
 185  	if d.Minutes() >= 1 {
 186  		duration.Minutes = math.Floor(d.Minutes())
 187  		d -= time.Duration(duration.Minutes) * nsPerMinute
 188  	}
 189  	duration.Seconds = d.Seconds()
 190  
 191  	return duration
 192  }
 193  
 194  // Format formats the given time.Duration into an ISO 8601 duration string (e.g., P1DT6H5M),
 195  // negative durations are prefixed with a minus sign, for a zero duration "PT0S" is returned.
 196  // Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
 197  // since obviously those things vary month to month and year to year.
 198  func Format(d time.Duration) string {
 199  	return FromTimeDuration(d).String()
 200  }
 201  
 202  // ToTimeDuration converts the *Duration to the standard library's time.Duration.
 203  // Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
 204  // since obviously those things vary month to month and year to year.
 205  func (duration *Duration) ToTimeDuration() time.Duration {
 206  	var timeDuration time.Duration
 207  
 208  	// zero checks are here to avoid unnecessary math operations, on a duration such as `PT5M`
 209  	if duration.Years != 0 {
 210  		timeDuration += time.Duration(math.Round(duration.Years * nsPerYear))
 211  	}
 212  	if duration.Months != 0 {
 213  		timeDuration += time.Duration(math.Round(duration.Months * nsPerMonth))
 214  	}
 215  	if duration.Weeks != 0 {
 216  		timeDuration += time.Duration(math.Round(duration.Weeks * nsPerWeek))
 217  	}
 218  	if duration.Days != 0 {
 219  		timeDuration += time.Duration(math.Round(duration.Days * nsPerDay))
 220  	}
 221  	if duration.Hours != 0 {
 222  		timeDuration += time.Duration(math.Round(duration.Hours * nsPerHour))
 223  	}
 224  	if duration.Minutes != 0 {
 225  		timeDuration += time.Duration(math.Round(duration.Minutes * nsPerMinute))
 226  	}
 227  	if duration.Seconds != 0 {
 228  		timeDuration += time.Duration(math.Round(duration.Seconds * nsPerSecond))
 229  	}
 230  	if duration.Negative {
 231  		timeDuration = -timeDuration
 232  	}
 233  
 234  	return timeDuration
 235  }
 236  
 237  // String returns the ISO8601 duration string for the *Duration
 238  func (duration *Duration) String() string {
 239  	d := "P"
 240  	hasTime := false
 241  
 242  	appendD := func(designator string, value float64, isTime bool) {
 243  		if !hasTime && isTime {
 244  			d += "T"
 245  			hasTime = true
 246  		}
 247  
 248  		d += strconv.FormatFloat(value, 'f', -1, 64) + designator
 249  	}
 250  
 251  	if duration.Years != 0 {
 252  		appendD("Y", duration.Years, false)
 253  	}
 254  
 255  	if duration.Months != 0 {
 256  		appendD("M", duration.Months, false)
 257  	}
 258  
 259  	if duration.Weeks != 0 {
 260  		appendD("W", duration.Weeks, false)
 261  	}
 262  
 263  	if duration.Days != 0 {
 264  		appendD("D", duration.Days, false)
 265  	}
 266  
 267  	if duration.Hours != 0 {
 268  		appendD("H", duration.Hours, true)
 269  	}
 270  
 271  	if duration.Minutes != 0 {
 272  		appendD("M", duration.Minutes, true)
 273  	}
 274  
 275  	if duration.Seconds != 0 {
 276  		appendD("S", duration.Seconds, true)
 277  	}
 278  
 279  	// if the duration is zero, return "PT0S"
 280  	if d == "P" {
 281  		d += "T0S"
 282  	}
 283  
 284  	if duration.Negative {
 285  		return "-" + d
 286  	}
 287  
 288  	return d
 289  }
 290  
 291  // MarshalJSON satisfies the Marshaler interface by return a valid JSON string representation of the duration
 292  func (duration Duration) MarshalJSON() ([]byte, error) {
 293  	return json.Marshal(duration.String())
 294  }
 295  
 296  // UnmarshalJSON satisfies the Unmarshaler interface by return a valid JSON string representation of the duration
 297  func (duration *Duration) UnmarshalJSON(source []byte) error {
 298  	durationString := ""
 299  	err := json.Unmarshal(source, &durationString)
 300  	if err != nil {
 301  		return err
 302  	}
 303  
 304  	parsed, err := Parse(durationString)
 305  	if err != nil {
 306  		return fmt.Errorf("failed to parse duration: %w", err)
 307  	}
 308  
 309  	*duration = *parsed
 310  	return nil
 311  }
 312