pub fn fixed_offset(abbr: &str, utoff: i32) -> String {
let mut s = String::new();
s.push_str("e_abbr(abbr));
s.push_str(&format_posix_offset(utoff));
s
}
pub fn format_posix_offset(utoff: i32) -> String {
let posix = -(utoff as i64); let neg = posix < 0;
let abs = posix.unsigned_abs();
let h = abs / 3600;
let m = (abs % 3600) / 60;
let sec = abs % 60;
let mut out = String::new();
if neg {
out.push('-');
}
out.push_str(&h.to_string());
if m != 0 || sec != 0 {
out.push_str(&format!(":{m:02}"));
if sec != 0 {
out.push_str(&format!(":{sec:02}"));
}
}
out
}
fn quote_abbr(abbr: &str) -> String {
if !abbr.is_empty() && abbr.bytes().all(|b| b.is_ascii_alphabetic()) {
abbr.to_string()
} else {
format!("<{abbr}>")
}
}
#[derive(Debug, Clone, Copy)]
pub struct Recurring<'a> {
pub std_abbr: &'a str,
pub std_utoff: i32,
pub dst_abbr: &'a str,
pub dst_utoff: i32,
pub dst_month: u8,
pub dst_on: crate::model::calendar::OnDay,
pub dst_at_wall: i32,
pub std_month: u8,
pub std_on: crate::model::calendar::OnDay,
pub std_at_wall: i32,
}
pub fn recurring(r: &Recurring<'_>) -> Option<(String, u8)> {
let mut s = String::new();
s.push_str("e_abbr(r.std_abbr));
s.push_str(&format_posix_offset(r.std_utoff));
s.push_str("e_abbr(r.dst_abbr));
if r.dst_utoff - r.std_utoff != 3600 {
s.push_str(&format_posix_offset(r.dst_utoff));
}
let (dst_rule, dst_shift) = date_rule(r.dst_month, r.dst_on)?;
let (std_rule, std_shift) = date_rule(r.std_month, r.std_on)?;
let dst_tod = r.dst_at_wall + dst_shift;
let std_tod = r.std_at_wall + std_shift;
s.push(',');
s.push_str(&dst_rule);
s.push_str(&time_suffix(dst_tod));
s.push(',');
s.push_str(&std_rule);
s.push_str(&time_suffix(std_tod));
let needs_v3 = dst_shift != 0 || std_shift != 0 || dst_tod < 0 || std_tod < 0;
let version = if needs_v3 { b'3' } else { b'2' };
Some((s, version))
}
fn date_rule(month: u8, on: crate::model::calendar::OnDay) -> Option<(String, i32)> {
use crate::model::calendar::OnDay;
const LEN: [u8; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let (week, weekday, shift_days): (i32, i32, i32) = match on {
OnDay::Last(wd) => (5, wd as i32, 0),
OnDay::OnAfter(wd, n) => {
let wdayoff = (n as i32 - 1).rem_euclid(7);
(1 + (n as i32 - 1) / 7, wd as i32 - wdayoff, wdayoff)
}
OnDay::OnBefore(wd, n) => {
if (1..=12).contains(&month) && n == LEN[(month - 1) as usize] {
(5, wd as i32, 0)
} else {
let wdayoff = (n as i32).rem_euclid(7);
(n as i32 / 7, wd as i32 - wdayoff, wdayoff)
}
}
OnDay::Day(_) => return None,
};
if !(1..=5).contains(&week) {
return None;
}
Some((
format!("M{month}.{week}.{}", weekday.rem_euclid(7)),
shift_days * 86400,
))
}
fn time_suffix(wall_seconds: i32) -> String {
const POSIX_DEFAULT: i32 = 2 * 3600;
if wall_seconds == POSIX_DEFAULT {
return String::new();
}
format!("/{}", format_posix_time(wall_seconds))
}
fn format_posix_time(seconds: i32) -> String {
let neg = seconds < 0;
let abs = seconds.unsigned_abs();
let h = abs / 3600;
let m = (abs % 3600) / 60;
let s = abs % 60;
let mut out = String::new();
if neg {
out.push('-');
}
out.push_str(&h.to_string());
if m != 0 || s != 0 {
out.push_str(&format!(":{m:02}"));
if s != 0 {
out.push_str(&format!(":{s:02}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_and_est() {
assert_eq!(fixed_offset("UTC", 0), "UTC0");
assert_eq!(fixed_offset("EST", -18000), "EST5");
}
#[test]
fn east_offset_is_negative() {
assert_eq!(format_posix_offset(9 * 3600), "-9");
}
#[test]
fn fractional_hours() {
assert_eq!(format_posix_offset(19800), "-5:30");
assert_eq!(format_posix_offset(-12600), "3:30");
}
#[test]
fn numeric_abbr_is_quoted() {
assert_eq!(fixed_offset("+05", 18000), "<+05>-5");
}
#[test]
fn recurring_us_eastern() {
use crate::model::calendar::{OnDay, Weekday};
let r = Recurring {
std_abbr: "EST",
std_utoff: -18000,
dst_abbr: "EDT",
dst_utoff: -14400,
dst_month: 3,
dst_on: OnDay::OnAfter(Weekday::Sun, 8), dst_at_wall: 2 * 3600, std_month: 11,
std_on: OnDay::OnAfter(Weekday::Sun, 1), std_at_wall: 2 * 3600,
};
assert_eq!(
recurring(&r).unwrap(),
("EST5EDT,M3.2.0,M11.1.0".to_string(), b'2')
);
}
#[test]
fn recurring_london_emits_nondefault_times_and_lastsun() {
use crate::model::calendar::{OnDay, Weekday};
let r = Recurring {
std_abbr: "GMT",
std_utoff: 0,
dst_abbr: "BST",
dst_utoff: 3600,
dst_month: 3,
dst_on: OnDay::Last(Weekday::Sun),
dst_at_wall: 3600, std_month: 10,
std_on: OnDay::Last(Weekday::Sun),
std_at_wall: 2 * 3600, };
assert_eq!(
recurring(&r).unwrap(),
("GMT0BST,M3.5.0/1,M10.5.0".to_string(), b'2')
);
}
#[test]
fn recurring_sat_leq_30_uses_v3_extended_time_footer() {
use crate::model::calendar::{OnDay, Weekday};
let r = Recurring {
std_abbr: "EET",
std_utoff: 7200,
dst_abbr: "EEST",
dst_utoff: 10800,
dst_month: 3,
dst_on: OnDay::OnBefore(Weekday::Sat, 30),
dst_at_wall: 2 * 3600,
std_month: 10,
std_on: OnDay::OnBefore(Weekday::Sat, 30),
std_at_wall: 2 * 3600,
};
assert_eq!(
recurring(&r).unwrap(),
("EET-2EEST,M3.4.4/50,M10.4.4/50".to_string(), b'3')
);
}
#[test]
fn recurring_dayshift_forces_v3_even_when_time_in_range() {
use crate::model::calendar::{OnDay, Weekday};
let r = Recurring {
std_abbr: "X5",
std_utoff: -18000,
dst_abbr: "X4",
dst_utoff: -14400,
dst_month: 9,
dst_on: OnDay::OnAfter(Weekday::Sun, 2),
dst_at_wall: -3600, std_month: 4,
std_on: OnDay::OnAfter(Weekday::Sun, 2),
std_at_wall: -3600,
};
let (footer, version) = recurring(&r).unwrap();
assert_eq!(version, b'3', "day-shift must force v3: {footer}");
assert!(
footer.contains("M9.1.6/23"),
"in-range folded time: {footer}"
);
}
#[test]
fn recurring_last_weekday_24h_stays_v2() {
use crate::model::calendar::{OnDay, Weekday};
let r = Recurring {
std_abbr: "EET",
std_utoff: 7200,
dst_abbr: "EEST",
dst_utoff: 10800,
dst_month: 4,
dst_on: OnDay::Last(Weekday::Fri),
dst_at_wall: 0,
std_month: 10,
std_on: OnDay::Last(Weekday::Thu),
std_at_wall: 24 * 3600, };
let (footer, version) = recurring(&r).unwrap();
assert_eq!(version, b'2', "24h with no day-shift stays v2: {footer}");
assert!(footer.contains("/24"), "{footer}");
}
#[test]
fn recurring_refuses_fixed_numeric_day() {
use crate::model::calendar::{OnDay, Weekday};
let r = Recurring {
std_abbr: "EST",
std_utoff: -18000,
dst_abbr: "EDT",
dst_utoff: -14400,
dst_month: 3,
dst_on: OnDay::OnAfter(Weekday::Sun, 8),
dst_at_wall: 2 * 3600,
std_month: 10,
std_on: OnDay::Day(25), std_at_wall: 2 * 3600,
};
assert!(recurring(&r).is_none());
}
#[test]
fn recurring_emits_explicit_dst_offset_when_nondefault() {
use crate::model::calendar::{OnDay, Weekday};
let r = Recurring {
std_abbr: "+1030",
std_utoff: 37800,
dst_abbr: "+11",
dst_utoff: 39600,
dst_month: 10,
dst_on: OnDay::OnAfter(Weekday::Sun, 1),
dst_at_wall: 2 * 3600,
std_month: 4,
std_on: OnDay::OnAfter(Weekday::Sun, 1),
std_at_wall: 3 * 3600,
};
assert_eq!(
recurring(&r).unwrap(),
("<+1030>-10:30<+11>-11,M10.1.0,M4.1.0/3".to_string(), b'2')
);
}
}