use crate::engines::actionlint::yaml::go_quote;
const STAR_BIT: u64 = 1 << 63;
struct Bounds {
min: u32,
max: u32,
names: &'static [(&'static str, u32)],
}
const MINUTES: Bounds = Bounds {
min: 0,
max: 59,
names: &[],
};
const HOURS: Bounds = Bounds {
min: 0,
max: 23,
names: &[],
};
const DOM: Bounds = Bounds {
min: 1,
max: 31,
names: &[],
};
const MONTHS: Bounds = Bounds {
min: 1,
max: 12,
names: &[
("jan", 1),
("feb", 2),
("mar", 3),
("apr", 4),
("may", 5),
("jun", 6),
("jul", 7),
("aug", 8),
("sep", 9),
("oct", 10),
("nov", 11),
("dec", 12),
],
};
const DOW: Bounds = Bounds {
min: 0,
max: 6,
names: &[
("sun", 0),
("mon", 1),
("tue", 2),
("wed", 3),
("thu", 4),
("fri", 5),
("sat", 6),
],
};
#[derive(Debug, Clone, Copy)]
pub struct Schedule {
minute: u64,
hour: u64,
dom: u64,
month: u64,
dow: u64,
}
pub fn parse(spec: &str) -> Result<Schedule, String> {
if spec.is_empty() {
return Err("empty spec string".to_string());
}
let mut spec = spec;
if spec.starts_with("TZ=") || spec.starts_with("CRON_TZ=") {
let (Some(i), Some(eq)) = (spec.find(' '), spec.find('=')) else {
return Err(format!(
"provided bad location {spec}: unknown time zone {spec}"
));
};
let name = &spec[eq + 1..i];
if !crate::engines::actionlint::rules::events::timezone_exists(name) {
return Err(format!(
"provided bad location {name}: unknown time zone {name}"
));
}
spec = spec[i..].trim();
}
if spec.starts_with('@') {
return Err(format!("parser does not accept descriptors: {spec}"));
}
let fields: Vec<&str> = spec.split_whitespace().collect();
if fields.len() != 5 {
return Err(format!(
"expected exactly 5 fields, found {}: [{}]",
fields.len(),
fields.join(" ")
));
}
Ok(Schedule {
minute: get_field(fields[0], &MINUTES)?,
hour: get_field(fields[1], &HOURS)?,
dom: get_field(fields[2], &DOM)?,
month: get_field(fields[3], &MONTHS)?,
dow: get_field(fields[4], &DOW)?,
})
}
fn get_field(field: &str, r: &Bounds) -> Result<u64, String> {
let mut bits = 0u64;
for expr in field.split(',').filter(|s| !s.is_empty()) {
bits |= get_range(expr, r)?;
}
Ok(bits)
}
fn get_range(expr: &str, r: &Bounds) -> Result<u64, String> {
let range_and_step: Vec<&str> = expr.split('/').collect();
let low_and_high: Vec<&str> = range_and_step[0].split('-').collect();
let single_digit = low_and_high.len() == 1;
let (start, mut end, mut extra) = if low_and_high[0] == "*" || low_and_high[0] == "?" {
(r.min, r.max, STAR_BIT)
} else {
let start = parse_int_or_name(low_and_high[0], r.names)?;
let end = match low_and_high.len() {
1 => start,
2 => parse_int_or_name(low_and_high[1], r.names)?,
_ => return Err(format!("too many hyphens: {expr}")),
};
(start, end, 0u64)
};
let step = match range_and_step.len() {
1 => 1,
2 => {
let step = must_parse_int(range_and_step[1])?;
if single_digit {
end = r.max;
}
if step > 1 {
extra = 0;
}
step
}
_ => return Err(format!("too many slashes: {expr}")),
};
if start < r.min {
return Err(format!(
"beginning of range ({start}) below minimum ({}): {expr}",
r.min
));
}
if end > r.max {
return Err(format!(
"end of range ({end}) above maximum ({}): {expr}",
r.max
));
}
if start > end {
return Err(format!(
"beginning of range ({start}) beyond end of range ({end}): {expr}"
));
}
if step == 0 {
return Err(format!("step of range should be a positive number: {expr}"));
}
Ok(get_bits(start, end, step) | extra)
}
fn parse_int_or_name(expr: &str, names: &[(&str, u32)]) -> Result<u32, String> {
let lower = expr.to_lowercase();
if let Some((_, v)) = names.iter().find(|(n, _)| *n == lower) {
return Ok(*v);
}
must_parse_int(expr)
}
fn must_parse_int(expr: &str) -> Result<u32, String> {
let body = expr.strip_prefix(['+', '-']).unwrap_or(expr);
if body.is_empty() || !body.bytes().all(|b| b.is_ascii_digit()) {
return Err(format!(
"failed to parse int from {expr}: strconv.Atoi: parsing {}: invalid syntax",
go_quote(expr)
));
}
let num: i64 = expr.parse().map_err(|_| {
format!(
"failed to parse int from {expr}: strconv.Atoi: parsing {}: value out of range",
go_quote(expr)
)
})?;
if num < 0 {
return Err(format!("negative number ({num}) not allowed: {expr}"));
}
u32::try_from(num).map_err(|_| format!("end of range ({num}) above maximum: {expr}"))
}
const fn get_bits(min: u32, max: u32, step: u32) -> u64 {
if step == 1 {
return !(u64::MAX << (max + 1)) & (u64::MAX << min);
}
let mut bits = 0u64;
let mut i = min;
while i <= max {
bits |= 1 << i;
i += step;
}
bits
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Civil {
year: i64,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
}
const fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
}
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (i64::from(m) + 9) % 12;
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
impl Civil {
const EPOCH: Self = Self {
year: 1970,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
};
fn to_seconds(self) -> i64 {
days_from_civil(self.year, self.month, self.day) * 86_400
+ i64::from(self.hour) * 3600
+ i64::from(self.minute) * 60
+ i64::from(self.second)
}
fn weekday(self) -> u32 {
let days = days_from_civil(self.year, self.month, self.day);
u32::try_from((days + 4).rem_euclid(7)).unwrap_or(0)
}
fn add_seconds(self, secs: i64) -> Self {
let total = self.to_seconds() + secs;
let days = total.div_euclid(86_400);
let rem = total.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
Self {
year: y,
month: u32::try_from(m).unwrap_or(1),
day: u32::try_from(d).unwrap_or(1),
hour: u32::try_from(rem / 3600).unwrap_or(0),
minute: u32::try_from(rem % 3600 / 60).unwrap_or(0),
second: u32::try_from(rem % 60).unwrap_or(0),
}
}
fn add_months(self, n: i64) -> Self {
let total = self.year * 12 + i64::from(self.month) - 1 + n;
let year = total.div_euclid(12);
let month = u32::try_from(total.rem_euclid(12) + 1).unwrap_or(1);
let dim = days_in_month(year, month);
let base = Self {
year,
month,
day: self.day.min(dim),
..self
};
let overflow = i64::from(self.day) - i64::from(base.day);
base.add_seconds(overflow * 86_400)
}
}
impl Schedule {
pub fn next(&self, t: i64) -> Option<i64> {
let mut t = Civil::EPOCH.add_seconds(t + 1);
let mut added = false;
let year_limit = t.year + 5;
'wrap: loop {
if t.year > year_limit {
return None;
}
while (1u64 << t.month) & self.month == 0 {
if !added {
added = true;
t = Civil {
day: 1,
hour: 0,
minute: 0,
second: 0,
..t
};
}
t = t.add_months(1);
if t.month == 1 {
continue 'wrap;
}
}
while !self.day_matches(t) {
if !added {
added = true;
t = Civil {
hour: 0,
minute: 0,
second: 0,
..t
};
}
t = t.add_seconds(86_400);
if t.day == 1 {
continue 'wrap;
}
}
while (1u64 << t.hour) & self.hour == 0 {
if !added {
added = true;
t = Civil {
minute: 0,
second: 0,
..t
};
}
t = t.add_seconds(3600);
if t.hour == 0 {
continue 'wrap;
}
}
while (1u64 << t.minute) & self.minute == 0 {
if !added {
added = true;
t = Civil { second: 0, ..t };
}
t = t.add_seconds(60);
if t.minute == 0 {
continue 'wrap;
}
}
while t.second != 0 {
if !added {
added = true;
}
t = t.add_seconds(1);
if t.second == 0 {
continue 'wrap;
}
}
return Some(t.to_seconds());
}
}
fn day_matches(&self, t: Civil) -> bool {
let day_of_month_ok = (1u64 << t.day) & self.dom > 0;
let weekday_ok = (1u64 << t.weekday()) & self.dow > 0;
if self.dom & STAR_BIT > 0 || self.dow & STAR_BIT > 0 {
return day_of_month_ok && weekday_ok;
}
day_of_month_ok || weekday_ok
}
}