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  }