github.com/ngicks/gokugen@v0.0.5/cron/cron.go (about) 1 package cron 2 3 import ( 4 "errors" 5 "fmt" 6 "strings" 7 "time" 8 9 "github.com/ngicks/type-param-common/slice" 10 ) 11 12 var ( 13 ErrMalformed = errors.New("malformed cron row") 14 ErrNoMatch = errors.New("no match") 15 ) 16 17 type CronTab []Row 18 19 var _ RowLike = Row{} 20 21 // Row is crontab row like data structure. 22 // Nil or empty time fields are treated as wildcard that matches every value of corresponding field. 23 // Row with all nil time fields is `Reboot`. NextSchedule of `Reboot` returns simply passed `now`. 24 // 25 // Command corresponds to a work of the row. 26 // Command[0] is used as command. Rest are treated as arguments for the command. 27 // 28 // Data range is as below 29 // 30 // | field | allowed value | 31 // | ------- | ----------------------------- | 32 // | Minute | 0-59 | 33 // | Hour | 0-23 | 34 // | Day | 1-31 | 35 // | Month | 1-12 | 36 // | Weekday | 0-6 | 37 // | Command | command name, param, param... | 38 // 39 // When any of time fields has value out of this range or command is invalid, IsValid() returns (false, non-empty-slice). 40 type Row struct { 41 Minute *Minutes `json:"minute,omitempty"` 42 Hour *Hours `json:"hour,omitempty"` 43 Day *Days `json:"day,omitempty"` 44 Month *Months `json:"month,omitempty"` 45 Weekday *Weekdays `json:"weekday,omitempty"` 46 Command []string `json:"command"` 47 } 48 49 // IsReboot returns true only if all 5 time fields are nil. 50 func (r Row) IsReboot() bool { 51 return r.Weekday == nil && r.Month == nil && r.Day == nil && r.Hour == nil && r.Minute == nil 52 } 53 54 // IsValid checks if r is valid. ok is false when any of time fields are out of allowed range 55 // or command is invalid. reason string slice is always non nil. 56 func (r Row) IsValid() (ok bool, reason []string) { 57 if !r.Minute.IsValid() { 58 reason = append(reason, fmt.Sprintf("invalid minute: %v", *r.Minute)) 59 } 60 if !r.Hour.IsValid() { 61 reason = append(reason, fmt.Sprintf("invalid hour: %v", *r.Hour)) 62 } 63 if !r.Day.IsValid() { 64 reason = append(reason, fmt.Sprintf("invalid day: %v", *r.Day)) 65 } 66 if !r.Month.IsValid() { 67 reason = append(reason, fmt.Sprintf("invalid month: %v", *r.Month)) 68 } 69 if !r.Weekday.IsValid() { 70 reason = append(reason, fmt.Sprintf("invalid weekDay: %v", *r.Weekday)) 71 } 72 if !r.IsCommandValid() { 73 reason = append(reason, fmt.Sprintf("command is invalid")) 74 } 75 76 return len(reason) == 0, reason 77 } 78 79 // IsCommandValid returns false if Command is nil or empty. returns true otherwise. 80 func (r Row) IsCommandValid() bool { 81 return r.Command != nil && len(r.Command) != 0 82 } 83 84 // NextSchedule returns time matched to r's configuration and most recent but after now. 85 // Returned time has same location as one of now. 86 // error is non nil if r is invalid. 87 func (r Row) NextSchedule(now time.Time) (time.Time, error) { 88 if ok, reason := r.IsValid(); !ok { 89 return time.Time{}, fmt.Errorf("%v: %s", ErrMalformed, strings.Join(reason, ", ")) 90 } 91 92 if r.IsReboot() { 93 return now, nil 94 } 95 96 var next time.Time 97 year := now.Year() 98 weekdays := r.Weekday.Get() 99 for i := 0; i <= 1; i++ { 100 monthLoop: 101 for _, m := range r.Month.Get() { 102 dayLoop: 103 for _, d := range r.Day.Get() { 104 hourLoop: 105 for _, h := range r.Hour.Get() { 106 minLoop: 107 for _, min := range r.Minute.Get() { 108 next = time.Date(year+i, m, int(d), int(h), int(min), 0, 0, now.Location()) 109 if next.Month() != m { 110 // Strong indication of overflow. 111 // Maybe leap year. 112 next = lastDayOfMonth(next.AddDate(0, -1, 0)) 113 } 114 if next.After(now) && slice.Has(weekdays, next.Weekday()) { 115 return next, nil 116 } 117 sub := now.Sub(next) 118 switch { 119 case sub > 365*24*time.Hour: 120 isLeapYear := IsLeapYear(next) 121 if !isLeapYear { 122 break monthLoop 123 } else if isLeapYear && sub > 366*24*time.Hour { 124 break monthLoop 125 } 126 case sub > 31*24*time.Hour: 127 break dayLoop 128 case sub > 24*time.Hour: 129 break hourLoop 130 case sub > time.Hour: 131 break minLoop 132 } 133 } 134 } 135 } 136 } 137 } 138 // Could this happen? 139 return now, ErrNoMatch 140 } 141 142 // GetCommand returns []string if Command is valid, nil otherwise. 143 // Mutating returned slice causes undefined behavior. 144 func (r Row) GetCommand() []string { 145 if r.IsCommandValid() { 146 return r.Command 147 } else { 148 return nil 149 } 150 } 151 152 func lastDayOfMonth(t time.Time) time.Time { 153 year, month, _ := t.Date() 154 h, m, s := t.Clock() 155 nano := t.Nanosecond() 156 return time.Date( 157 year, 158 month, 159 1, 160 h, 161 m, 162 s, 163 nano, 164 t.Location(), 165 ).AddDate(0, 1, -1) 166 } 167 168 func IsLeapYear(t time.Time) bool { 169 year := time.Date(t.Year(), time.December, 31, 0, 0, 0, 0, time.UTC) 170 return year.YearDay() == 366 171 }