-// save writes the file whole, with mode 0600. The caller holds the mutex.
-func (s *Schedules) save() error {
- data, err := json.Marshal(s.file)
- if err == nil {
- err = writeFileAtomic(s.path, append(data, '\n'))
+// Set adds a schedule with text and returns the answer of the tool. Exactly one
+// of at and cron is not nil. at is a local time in scheduleAtLayout after now,
+// and it goes through scheduleWallTime, so a time in the hour that the clock
+// repeats is its first occurrence. cron is an expression of scheduleParseCron.
+func (s *Schedules) Set(text string, at, cron *string) (string, error) {
+ text = strings.TrimSpace(text)
+ if text == "" || len(text) > scheduleTextBytes {
+ return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes)
+ }
+
+ now := s.clock()
+ schedule := Schedule{
+ Text: text,
+ Owner: s.owner,
+ }
+
+ switch {
+ case (at == nil) == (cron == nil):
+ return "", errors.New("give either at or cron, and null for the other")
+ case at != nil:
+ parsed, err := time.Parse(scheduleAtLayout, *at)
+ if err != nil {
+ return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout))
+ }
+
+ schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location())
+ if !schedule.Due.After(now) {
+ return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout))
+ }
+ default:
+ parsed, err := scheduleParseCron(*cron)
+ if err != nil {
+ return "", fmt.Errorf("parse schedule expression: %w", err)
+ }
+
+ next, found := parsed.Next(now)
+ if !found {
+ return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears)
+ }
+
+ schedule.Cron = parsed.String()
+ schedule.Due = next