uhrwerk 0.1.0

Simple scheduler for recurring tasks
Documentation
use crate::{
	Interval, NextTime,
	intervals::{RunConfig, parse_time},
	timeprovider::{DefaultTimeProvider, TimeProvider}
};
use std::{
	fmt::{self, Debug, Formatter},
	marker::PhantomData
};
use time::{OffsetDateTime, Time, UtcOffset};

#[doc(hidden)]
pub trait WithSchedule<TP> {
	fn schedule_mut(&mut self) -> &mut JobSchedule<TP>;
	fn schedule(&self) -> &JobSchedule<TP>;
}

pub struct Repeating<'a, T, TP> {
	job: &'a mut T,
	interval: Interval,
	_tp: PhantomData<TP>
}

impl<'a, T, TP> Repeating<'a, T, TP>
where
	T: WithSchedule<TP>,
	TP: TimeProvider
{
	pub(crate) fn new(job: &'a mut T, interval: Interval) -> Repeating<'a, T, TP> {
		Self {
			job,
			interval,
			_tp: PhantomData
		}
	}

	/// Indicate the number of additoinal times the job should be run every time it's scheduled.
	/// Passing a value of 0 here is the same as not specifying a repeat at all.
	pub fn times(self, n: usize) -> &'a mut T {
		if n >= 1 {
			self.job.schedule_mut().repeat_config = Some(RepeatConfig {
				repeats: n,
				repeat_interval: self.interval,
				repeats_left: 0
			});
		}
		self.job
	}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RunCount {
	Never,
	Times(usize),
	Forever
}

#[derive(Debug, Clone)]
pub(crate) struct RepeatConfig {
	repeats: usize,
	repeat_interval: Interval,
	repeats_left: usize
}

pub struct JobSchedule<TP = DefaultTimeProvider> {
	frequency: Vec<RunConfig>,
	next_run: Option<OffsetDateTime>,
	last_run: Option<OffsetDateTime>,
	run_count: RunCount,
	repeat_config: Option<RepeatConfig>,
	utc_offset: UtcOffset,
	_tp: PhantomData<TP>
}

impl<TP> Debug for JobSchedule<TP> {
	fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
		f.debug_struct("JobSchedule")
			.field("frequency", &self.frequency)
			.field("next_run", &self.next_run)
			.field("last_run", &self.last_run)
			.field("run_count", &self.run_count)
			.field("repeat_config", &self.repeat_config)
			.finish()
	}
}

impl<TP: TimeProvider> JobSchedule<TP> {
	pub(crate) fn new(ival: Interval, utc_offset: UtcOffset) -> Self {
		Self {
			frequency: vec![RunConfig::from_interval(ival)],
			next_run: None,
			last_run: None,
			run_count: RunCount::Forever,
			repeat_config: None,
			utc_offset,
			_tp: PhantomData
		}
	}

	fn last_frequency(&mut self) -> &mut RunConfig {
		let last_idx = self.frequency.len() - 1;
		&mut self.frequency[last_idx]
	}

	pub fn at(&mut self, time: &str) -> &mut Self {
		self.try_at(time)
			.expect("Could not convert value into a time")
	}

	pub fn try_at(&mut self, time: &str) -> Result<&mut Self, time::error::Parse> {
		Ok(self.at_time(parse_time(time)?))
	}

	pub fn at_time(&mut self, time: Time) -> &mut Self {
		let frequency = self.last_frequency();
		*frequency = frequency.with_time(time);
		self
	}

	pub fn plus(&mut self, ival: Interval) -> &mut Self {
		let frequency = self.last_frequency();
		*frequency = frequency.with_subinterval(ival);
		self
	}

	pub fn and_every(&mut self, ival: Interval) -> &mut Self {
		self.frequency.push(RunConfig::from_interval(ival));
		self
	}

	pub fn once(&mut self) -> &mut Self {
		self.run_count = RunCount::Times(1);
		self
	}

	pub fn forever(&mut self) -> &mut Self {
		self.run_count = RunCount::Forever;
		self
	}

	pub fn count(&mut self, count: usize) -> &mut Self {
		self.run_count = RunCount::Times(count);
		self
	}

	fn next_run_time(&self, now: OffsetDateTime) -> Option<OffsetDateTime> {
		match self.run_count {
			RunCount::Never => None,
			_ => self.frequency.iter().map(|freq| freq.next(now)).min()
		}
	}

	/// Has this job exhausted its runs?
	pub fn can_run_again(&self) -> bool {
		self.run_count != RunCount::Never
	}

	/// Specify a task to run, and schedule its next run
	pub fn start_schedule(&mut self) -> &mut Self {
		if self.next_run.is_none() {
			let now = TP::now(self.utc_offset);
			self.next_run = self.next_run_time(now);
			if let Some(RepeatConfig {
				repeats,
				repeats_left,
				..
			}) = &mut self.repeat_config
			{
				*repeats_left = *repeats;
			}
		}
		self
	}

	/// Test whether a job is scheduled to run again. This is usually only called by
	/// [Scheduler::run_pending()](::Scheduler::run_pending).
	pub fn is_pending(&self, now: OffsetDateTime) -> bool {
		self.next_run.is_some_and(|dt| dt <= now)
	}

	/// Run a task and re-schedule it. This is usually only called by
	/// [Scheduler::run_pending()](::Scheduler::run_pending).
	pub fn schedule_next(&mut self, now: OffsetDateTime) {
		// Don't do anything if we're run out of runs
		if self.run_count == RunCount::Never {
			return;
		}

		// We compute this up front since we can't borrow self immutably while doing this next bit
		let next_run_time = self.next_run_time(now);
		match &mut self.repeat_config {
			Some(RepeatConfig {
				repeats,
				repeats_left,
				repeat_interval
			}) => {
				if *repeats_left > 0 {
					*repeats_left -= 1;
					// Normal scheduling is aligned with the day: if you ask for something every hour, it will
					// run at the start of the next hour, not one hour after being scheduled.
					// For repeats, though, we want them aligned the first run of the repeats.
					// This means we want to align with with next_run (which should be not far in the past),
					// or if it's somehow unavailable, the current time.
					// It's possible that we're really far behind. If so, find the next repeat interval that's
					// still in the future (relative to when we start this run.)
					let mut next = self.next_run.unwrap_or(now);
					loop {
						next = repeat_interval.next_from(next);
						if next > now {
							break;
						}
					}
					self.next_run = Some(next);
				} else {
					self.next_run = next_run_time;
					*repeats_left = *repeats;
				}
			},
			None => self.next_run = next_run_time
		}

		self.last_run = Some(now);
		self.run_count = match self.run_count {
			RunCount::Never => RunCount::Never,
			RunCount::Times(n) if n > 1 => RunCount::Times(n - 1),
			RunCount::Times(_) => RunCount::Never,
			RunCount::Forever => RunCount::Forever
		};
	}
}

#[cfg(test)]
mod test {
	use super::JobSchedule;
	use crate::{Job, SyncJob, intervals::*, timeprovider::TimeProvider};
	use time::{Date, Month, OffsetDateTime, Time, UtcDateTime, UtcOffset};

	fn utc_hms(h: u8, m: u8, s: u8) -> OffsetDateTime {
		UtcDateTime::new(
			Date::from_calendar_date(2020, Month::June, 16).unwrap(),
			Time::from_hms(h, m, s).unwrap()
		)
		.into()
	}

	struct TestTimeProvider;

	impl TimeProvider for TestTimeProvider {
		fn now(utc_offset: UtcOffset) -> OffsetDateTime {
			utc_hms(7, 58, 0).to_offset(utc_offset)
		}
	}

	#[test]
	fn test_repeating() {
		let mut job = SyncJob::<TestTimeProvider>::new(1.hour(), UtcOffset::UTC);
		job.repeating_every(45.minutes()).times(2);
		job.run(|| {});

		assert!(!job.is_pending(utc_hms(7, 59, 0)));
		assert!(job.is_pending(utc_hms(8, 0, 0)));
		job.execute(utc_hms(8, 0, 0));

		assert!(!job.is_pending(utc_hms(8, 44, 0)));
		assert!(job.is_pending(utc_hms(8, 45, 0)));
		job.execute(utc_hms(8, 45, 0));

		// Skips 9:00 because it's still repeating at 45 minutes
		assert!(!job.is_pending(utc_hms(9, 0, 0)));

		assert!(!job.is_pending(utc_hms(9, 29, 0)));
		assert!(job.is_pending(utc_hms(9, 30, 0)));
		job.execute(utc_hms(9, 30, 0));

		assert!(!job.is_pending(utc_hms(9, 59, 0)));
		assert!(job.is_pending(utc_hms(10, 0, 0)));
	}

	#[test]
	fn test_time_coercion() {
		let mut job = JobSchedule::<TestTimeProvider>::new(1.day(), UtcOffset::UTC);
		// &str
		job.try_at("12:32").unwrap();
		// &String
		job.try_at(&format!("{}:{}", 12, 32)).unwrap();
		// NaiveTime
		job.at_time(Time::from_hms(12, 32, 0).unwrap());
	}
}