use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use crate::routine::RoutineSchedule;
use crate::routine_cadence::{cadence_text, zone_label};
use crate::routine_next_fire::{next_n_fires_after, schedule_zone};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreviewFire {
pub local_time: DateTime<Tz>,
pub utc_time: DateTime<Utc>,
pub zone_label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoutinePreview {
pub prompt_text: String,
pub next_fires: Vec<PreviewFire>,
pub zone_name: String,
pub zone_is_fallback: bool,
pub cadence: String,
}
#[must_use]
pub fn build_routine_preview(
schedule: &RoutineSchedule,
prompt: &str,
admin_zone: Option<Tz>,
now: DateTime<Utc>,
) -> RoutinePreview {
let (zone, zone_is_fallback) =
admin_zone.map_or_else(|| (schedule_zone(schedule), true), |z| (z, false));
let mut next_fires: Vec<PreviewFire> = next_n_fires_after(schedule, now, 3)
.into_iter()
.map(|fire| {
let local_time = fire.at.with_timezone(&zone);
PreviewFire {
local_time,
utc_time: fire.at,
zone_label: zone_label(local_time),
}
})
.collect();
let cadence = cadence_text(schedule).map_or_else(String::new, |cadence| {
let shared = next_fires
.split_first()
.filter(|(head, rest)| rest.iter().all(|f| f.zone_label == head.zone_label))
.map(|(head, _)| head.zone_label.clone());
match shared.filter(|_| cadence.has_time_of_day) {
Some(label) => {
for fire in &mut next_fires {
fire.zone_label = String::new();
}
format!("{} {label}", cadence.phrase)
}
None => cadence.phrase,
}
});
RoutinePreview {
prompt_text: prompt.to_owned(),
next_fires,
zone_name: zone.name().to_owned(),
zone_is_fallback,
cadence,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use crate::routine::RoutineSchedule;
fn utc(s: &str) -> DateTime<Utc> {
s.parse().expect("valid RFC3339")
}
fn cron(expr: &str, tz: Option<&str>) -> RoutineSchedule {
RoutineSchedule::Cron {
expression: expr.to_owned(),
timezone: tz.map(str::to_owned),
}
}
#[test]
fn preview_prompt_text_is_the_compiled_specs_prompt_verbatim() {
let schedule = cron("0 9 * * *", Some("America/New_York"));
let preview = build_routine_preview(
&schedule,
"Post the daily standup summary.",
None,
utc("2026-07-20T00:00:00Z"),
);
assert_eq!(preview.prompt_text, "Post the daily standup summary.");
}
#[test]
fn preview_next_fires_match_the_shared_next_n_fires_after_callable() {
let schedule = cron("0 9 * * *", Some("America/New_York"));
let now = utc("2026-07-20T00:00:00Z");
let preview = build_routine_preview(&schedule, "prompt", None, now);
let expected = next_n_fires_after(&schedule, now, 3);
assert_eq!(preview.next_fires.len(), expected.len());
for (fire, exp) in preview.next_fires.iter().zip(expected.iter()) {
assert_eq!(fire.utc_time, exp.at);
assert_eq!(fire.local_time, exp.at.with_timezone(&exp.zone));
}
}
#[test]
fn known_admin_zone_renders_local_times_in_that_zone_and_is_not_a_fallback() {
let schedule = cron("0 9 * * *", None); let admin_zone: Tz = "Asia/Tokyo".parse().unwrap();
let preview = build_routine_preview(
&schedule,
"prompt",
Some(admin_zone),
utc("2026-07-20T00:00:00Z"),
);
assert!(!preview.zone_is_fallback);
assert_eq!(preview.zone_name, "Asia/Tokyo");
assert_eq!(preview.next_fires[0].local_time.timezone(), admin_zone);
}
#[test]
fn unknown_admin_zone_falls_back_to_the_schedules_own_zone_and_labels_it() {
let schedule = cron("0 9 * * *", Some("America/New_York"));
let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-20T00:00:00Z"));
assert!(preview.zone_is_fallback);
assert_eq!(preview.zone_name, "America/New_York");
}
#[test]
fn a_single_clock_time_carries_the_zone_label_on_the_cadence_line() {
let schedule = cron("0 9 * * 1-5", Some("America/Los_Angeles"));
let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-28T00:00:00Z"));
assert_eq!(preview.cadence, "every weekday at 9:00 AM PDT (UTC-7)");
assert!(
preview.next_fires.iter().all(|f| f.zone_label.is_empty()),
"the cadence line already states the zone"
);
}
#[test]
fn a_cadence_without_a_clock_time_labels_each_run_instead() {
let schedule = cron("*/15 * * * *", Some("America/Los_Angeles"));
let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-28T00:00:00Z"));
assert_eq!(preview.cadence, "every 15 minutes");
assert!(
preview
.next_fires
.iter()
.all(|f| f.zone_label == "PDT (UTC-7)"),
"each run must state the zone the cadence line cannot"
);
}
#[test]
fn a_daylight_saving_straddle_labels_each_run() {
let schedule = cron("0 9 * * *", Some("America/Los_Angeles"));
let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-10-30T18:00:00Z"));
assert_eq!(preview.cadence, "every day at 9:00 AM");
let labels: Vec<&str> = preview
.next_fires
.iter()
.map(|f| f.zone_label.as_str())
.collect();
assert_eq!(labels, ["PDT (UTC-7)", "PST (UTC-8)", "PST (UTC-8)"]);
}
#[test]
fn an_undescribable_schedule_has_no_cadence_and_labels_its_runs() {
let schedule = cron("0 9-17 * * *", Some("America/Los_Angeles"));
let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-28T00:00:00Z"));
assert!(preview.cadence.is_empty());
assert!(
preview
.next_fires
.iter()
.all(|f| f.zone_label == "PDT (UTC-7)")
);
}
#[test]
fn a_completed_once_schedule_previews_no_fires() {
let schedule = RoutineSchedule::Once {
at: "2026-07-20T00:10:00Z".to_owned(),
};
let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-20T00:20:00Z"));
assert!(preview.next_fires.is_empty());
}
#[test]
fn a_once_schedule_puts_the_zone_label_on_its_run_not_the_cadence() {
let schedule = RoutineSchedule::Once {
at: "2026-07-21T09:00:00Z".to_owned(),
};
let admin_zone: Tz = "America/Los_Angeles".parse().unwrap();
let preview = build_routine_preview(
&schedule,
"prompt",
Some(admin_zone),
utc("2026-07-20T00:00:00Z"),
);
assert_eq!(preview.cadence, "once");
assert_eq!(preview.next_fires.len(), 1);
assert!(!preview.next_fires[0].zone_label.is_empty());
}
}