lazyBoy/crates/api/src/schedules.rs

809 lines
25 KiB
Rust
Raw Normal View History

//! Recurring bot runs. Chat or the computer panel can create them; a tick
//! loop turns due rows into ordinary queued runs.
use std::str::FromStr;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use cron::Schedule;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sqlx::FromRow;
use uuid::Uuid;
use crate::db::Actor;
use crate::state::AppState;
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct ScheduleRow {
pub id: String,
pub bot_id: String,
pub thread_id: Option<String>,
pub name: String,
pub cron: String,
pub timezone: String,
pub instructions: String,
pub enabled: bool,
pub last_run_at: Option<DateTime<Utc>>,
pub next_run_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSchedule {
pub name: String,
pub cron: String,
#[serde(default)]
pub timezone: String,
pub instructions: String,
#[serde(default)]
pub thread_id: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSchedule {
pub name: Option<String>,
pub cron: Option<String>,
pub timezone: Option<String>,
pub instructions: Option<String>,
pub enabled: Option<bool>,
pub thread_id: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/bots/{bot_id}/schedules",
get(list_http).post(create_http),
)
.route(
"/api/schedules/{id}",
axum::routing::patch(update_http).delete(delete_http),
)
.route("/api/schedules/{id}/run", post(run_now_http))
}
async fn scoped_actor(state: &AppState, bot_id: &str) -> Result<Actor, StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state
.db
.get_bot(&actor, bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(actor)
}
async fn list_http(
State(state): State<AppState>,
Path(bot_id): Path<String>,
) -> Result<Json<Vec<Value>>, StatusCode> {
let actor = scoped_actor(&state, &bot_id).await?;
let rows = list(&state, &actor, &bot_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(rows.into_iter().map(public_json).collect()))
}
async fn create_http(
State(state): State<AppState>,
Path(bot_id): Path<String>,
Json(input): Json<CreateSchedule>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = scoped_actor(&state, &bot_id)
.await
.map_err(|status| (status, Json(json!({"message":"bot not found"}))))?;
create(&state, &actor, &bot_id, input)
.await
.map(|row| Json(public_json(row)))
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))
}
async fn update_http(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<UpdateSchedule>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = state.bootstrap().await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"actor"})),
)
})?;
update(&state, &actor, &id, input)
.await
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
.map(|row| Json(public_json(row)))
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"schedule not found"})),
))
}
async fn delete_http(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
let actor = state
.bootstrap()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let deleted = sqlx::query("DELETE FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3")
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.execute(state.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if deleted.rows_affected() == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(StatusCode::NO_CONTENT)
}
async fn run_now_http(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let actor = state.bootstrap().await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message":"actor"})),
)
})?;
let row = get_row(&state, &actor, &id)
.await
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))?
.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"message":"schedule not found"})),
))?;
enqueue(&state, &row, false)
.await
.map(|run_id| Json(json!({"ok": true, "runId": run_id})))
.map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))
}
pub fn public_json(row: ScheduleRow) -> Value {
json!({
"id": row.id,
"botId": row.bot_id,
"threadId": row.thread_id,
"name": row.name,
"cron": row.cron,
"timezone": row.timezone,
"instructions": row.instructions,
"enabled": row.enabled,
"human": describe_cron(&row.cron),
"lastRunAt": row.last_run_at,
"nextRunAt": row.next_run_at,
"createdAt": row.created_at,
"updatedAt": row.updated_at,
})
}
pub async fn list(
state: &AppState,
actor: &Actor,
bot_id: &str,
) -> Result<Vec<ScheduleRow>, String> {
sqlx::query_as(
"SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at
FROM schedules
WHERE bot_id=$1 AND space_id=$2 AND user_id=$3
ORDER BY enabled DESC, next_run_at ASC NULLS LAST, updated_at DESC",
)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())
}
pub async fn get_row(
state: &AppState,
actor: &Actor,
id: &str,
) -> Result<Option<ScheduleRow>, String> {
sqlx::query_as(
"SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at
FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())
}
pub async fn create(
state: &AppState,
actor: &Actor,
bot_id: &str,
input: CreateSchedule,
) -> Result<ScheduleRow, String> {
validate_thread(state, actor, bot_id, input.thread_id.as_deref()).await?;
let name = clean_name(&input.name)?;
let instructions = clean_instructions(&input.instructions)?;
let cron = validate_cron(&input.cron)?;
let timezone = validate_timezone(&input.timezone)?;
let next = if input.enabled {
Some(next_fire(&cron, &timezone, Utc::now())?)
} else {
None
};
let id = Uuid::new_v4().to_string();
sqlx::query_as(
"INSERT INTO schedules
(id, space_id, user_id, bot_id, thread_id, name, cron, timezone, instructions, enabled, next_run_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at",
)
.bind(&id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.bind(bot_id)
.bind(input.thread_id.as_deref())
.bind(name)
.bind(&cron)
.bind(&timezone)
.bind(instructions)
.bind(input.enabled)
.bind(next)
.fetch_one(state.pool())
.await
.map_err(|error| error.to_string())
}
async fn update(
state: &AppState,
actor: &Actor,
id: &str,
input: UpdateSchedule,
) -> Result<Option<ScheduleRow>, String> {
let existing = match get_row(state, actor, id).await? {
Some(row) => row,
None => return Ok(None),
};
let name = match input.name {
Some(value) => clean_name(&value)?,
None => existing.name.clone(),
};
let instructions = match input.instructions {
Some(value) => clean_instructions(&value)?,
None => existing.instructions.clone(),
};
let cron = match input.cron {
Some(value) => validate_cron(&value)?,
None => existing.cron.clone(),
};
let timezone = match input.timezone {
Some(value) => validate_timezone(&value)?,
None => existing.timezone.clone(),
};
let enabled = input.enabled.unwrap_or(existing.enabled);
let thread_id = input.thread_id.or(existing.thread_id);
validate_thread(state, actor, &existing.bot_id, thread_id.as_deref()).await?;
let next = if enabled {
Some(next_fire(&cron, &timezone, Utc::now())?)
} else {
None
};
sqlx::query_as(
"UPDATE schedules
SET name=$1, cron=$2, timezone=$3, instructions=$4, enabled=$5, thread_id=$6,
next_run_at=$7, updated_at=now()
WHERE id=$8 AND space_id=$9 AND user_id=$10
RETURNING id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at",
)
.bind(name)
.bind(cron)
.bind(timezone)
.bind(instructions)
.bind(enabled)
.bind(thread_id)
.bind(next)
.bind(id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())
}
pub async fn tick_loop(state: AppState) {
loop {
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
if let Err(error) = fire_due(&state).await {
tracing::warn!("schedule tick failed: {error}");
}
}
}
async fn fire_due(state: &AppState) -> Result<(), String> {
let due: Vec<ScheduleRow> = sqlx::query_as(
"SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled,
last_run_at, next_run_at, created_at, updated_at
FROM schedules
WHERE enabled AND next_run_at IS NOT NULL AND next_run_at <= now()
ORDER BY next_run_at ASC
LIMIT 8",
)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
for row in due {
if let Err(error) = enqueue(state, &row, true).await {
tracing::warn!("schedule {} failed to enqueue: {error}", row.id);
}
}
Ok(())
}
async fn enqueue(state: &AppState, row: &ScheduleRow, from_tick: bool) -> Result<String, String> {
let mut tx = state.pool().begin().await.map_err(|e| e.to_string())?;
let locked: Option<ScheduleRow> = if from_tick {
sqlx::query_as("SELECT id,bot_id,thread_id,name,cron,timezone,instructions,enabled,last_run_at,next_run_at,created_at,updated_at FROM schedules WHERE id=$1 AND enabled AND next_run_at<=now() FOR UPDATE SKIP LOCKED")
.bind(&row.id).fetch_optional(&mut *tx).await.map_err(|e| e.to_string())?
} else {
Some(row.clone())
};
let Some(row) = locked.as_ref() else {
return Ok(String::new());
};
let bot = sqlx::query_as::<_, (String, String, String)>(
"SELECT space_id, user_id, id FROM bots WHERE id=$1",
)
.bind(&row.bot_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?;
let thread_id = match row.thread_id.as_deref() {
Some(id) => id.to_string(),
None => latest_thread(state, &row.bot_id).await?,
};
validate_thread(
state,
&Actor {
space_id: bot.0.clone(),
user_id: bot.1.clone(),
},
&row.bot_id,
Some(&thread_id),
)
.await?;
let prompt = format!("Scheduled task 「{}」:\n{}", row.name, row.instructions);
let seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_message_seq=next_message_seq+1, updated_at=now()
WHERE id=$1 RETURNING next_message_seq-1",
)
.bind(&thread_id)
.fetch_one(&mut *tx)
.await
.map_err(|error| error.to_string())?;
let run_id = Uuid::new_v4().to_string();
let message_id = Uuid::new_v4().to_string();
let body = if from_tick {
format!("[排程] {}", row.name)
} else {
format!("[排程試跑] {}", row.name)
};
sqlx::query(
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id)
VALUES ($1,$2,$3,'user',$4,$5,$6)",
)
.bind(&message_id)
.bind(&thread_id)
.bind(seq)
.bind(&body)
2026-09-06 05:39:06 +00:00
.bind(json!([{"kind":"scheduleRun","scheduleId":row.id,"name":row.name,"cron":row.cron,"human":describe_cron(&row.cron)}]))
.bind(&run_id)
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
sqlx::query(
"INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint)
VALUES ($1,$2,$3,$4,$5,'queued',$6,$7)",
)
.bind(&run_id)
.bind(&bot.0)
.bind(&row.bot_id)
.bind(&thread_id)
.bind(&bot.1)
.bind(&prompt)
.bind(json!({"messageSeq": seq, "scheduleId": row.id}))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
if from_tick {
let next = next_after_due(
&row.cron,
&row.timezone,
row.next_run_at.unwrap_or_else(Utc::now),
Utc::now(),
)?;
sqlx::query(
"UPDATE schedules SET last_run_at=now(),next_run_at=$2,updated_at=now() WHERE id=$1",
)
.bind(&row.id)
.bind(next)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
tx.commit().await.map_err(|error| error.to_string())?;
2026-09-07 13:08:10 +00:00
// A scheduled run starts with nobody pressing send, so the transcript line
// has to announce itself: without this an open chat stays blind to the run
// until it happens to poll.
let _ = crate::sessions::append_event(
state,
&thread_id,
"message.created",
json!({"id":message_id,"seq":seq,"role":"user","body":body,"runId":run_id}),
)
.await;
Ok(run_id)
}
async fn latest_thread(state: &AppState, bot_id: &str) -> Result<String, String> {
sqlx::query_scalar(
"SELECT id FROM threads
WHERE bot_id=$1 AND status='active' AND room_id IS NULL
ORDER BY updated_at DESC LIMIT 1",
)
.bind(bot_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "bot has no conversation to run this schedule in".into())
}
fn clean_name(name: &str) -> Result<String, String> {
let value = name.trim();
if value.is_empty() || value.chars().count() > 80 {
return Err("name must be 180 characters".into());
}
Ok(value.to_string())
}
fn clean_instructions(text: &str) -> Result<String, String> {
let value = text.trim();
if value.is_empty() || value.chars().count() > 8_000 {
return Err("instructions must be 18000 characters".into());
}
Ok(value.to_string())
}
pub fn validate_cron(expr: &str) -> Result<String, String> {
let trimmed = expr.trim();
if interval_seconds(trimmed)?.is_none() {
parse_schedule(trimmed)?;
}
Ok(trimmed.to_string())
}
fn validate_timezone(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok("Asia/Taipei".into());
}
Tz::from_str(trimmed).map_err(|_| "invalid IANA timezone".to_string())?;
Ok(trimmed.to_string())
}
fn interval_seconds(expr: &str) -> Result<Option<i64>, String> {
let Some(raw) = expr.strip_prefix("@every ") else {
return Ok(None);
};
if !raw.is_ascii() || raw.len() < 2 {
return Err("invalid fixed interval".into());
}
let (number, unit) = raw.split_at(raw.len() - 1);
let n: i64 = number.parse().map_err(|_| "invalid interval")?;
if !(1..=365).contains(&n) {
return Err("interval must be 1365".into());
}
let scale = match unit {
"m" => 60,
"h" => 3600,
"d" => 86400,
_ => return Err("interval unit must be m, h, or d".into()),
};
Ok(Some(n * scale))
}
fn parse_schedule(expr: &str) -> Result<Schedule, String> {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err("cron must have exactly 5 fields".into());
}
if fields[2] != "*" && fields[4] != "*" {
return Err("use either day-of-month or day-of-week, not both".into());
}
let weekday = standard_weekdays(fields[4])?;
let six = format!(
"0 {} {} {} {} {}",
fields[0], fields[1], fields[2], fields[3], weekday
);
Schedule::from_str(&six).map_err(|error| format!("invalid cron: {error}"))
}
// UI/API use Unix weekdays (0/7=Sun, 1=Mon); cron crate uses 1=Sun.
fn standard_weekdays(field: &str) -> Result<String, String> {
if field == "*" {
return Ok("*".into());
}
fn value(raw: &str) -> Result<u32, String> {
match raw.to_ascii_uppercase().as_str() {
"SUN" => Ok(0),
"MON" => Ok(1),
"TUE" => Ok(2),
"WED" => Ok(3),
"THU" => Ok(4),
"FRI" => Ok(5),
"SAT" => Ok(6),
_ => raw
.parse::<u32>()
.ok()
.filter(|n| *n <= 7)
.ok_or_else(|| "invalid weekday".into()),
}
}
let mut days = std::collections::BTreeSet::new();
for part in field.split(',') {
let (base, step) = if let Some((base, n)) = part.split_once('/') {
(
base,
n.parse::<u32>()
.ok()
.filter(|n| (1..=7).contains(n))
.ok_or("invalid weekday step")?,
)
} else {
(part, 1)
};
let (start, end) = if base == "*" {
(0, 6)
} else if let Some((a, b)) = base.split_once('-') {
(value(a)?, value(b)?)
} else {
let n = value(base)?;
(n, n)
};
if start > end {
return Err("weekday range must be ascending".into());
}
for n in (start..=end).step_by(step as usize) {
days.insert(n % 7 + 1);
}
}
Ok(days
.into_iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(","))
}
pub fn next_fire(expr: &str, timezone: &str, from: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
if let Some(seconds) = interval_seconds(expr)? {
return Ok(from + chrono::TimeDelta::seconds(seconds));
}
let schedule = parse_schedule(expr)?;
let tz: Tz = Tz::from_str(timezone).map_err(|_| "invalid IANA timezone")?;
let local = from.with_timezone(&tz);
schedule
.after(&local)
.next()
.map(|when| when.with_timezone(&Utc))
.ok_or_else(|| "cron has no future run".into())
}
pub fn describe_cron(expr: &str) -> String {
if let Ok(Some(seconds)) = interval_seconds(expr) {
return format!("每隔 {} 分鐘(固定間隔)", seconds / 60);
}
let parts: Vec<&str> = expr.split_whitespace().collect();
if parts.len() != 5 {
return expr.to_string();
}
let (min, hour, dom, month, dow) = (parts[0], parts[1], parts[2], parts[3], parts[4]);
if expr.trim() == "* * * * *" {
return "每分鐘".into();
}
if let Some(rest) = min.strip_prefix("*/")
&& hour == "*"
&& dom == "*"
&& month == "*"
&& dow == "*"
{
return if rest
.parse::<u32>()
.ok()
.is_some_and(|n| n > 0 && 60 % n == 0)
{
format!("{rest} 分鐘")
} else {
format!("日曆排程:{expr}")
};
}
if min == "0" && hour == "*" && dom == "*" && month == "*" && dow == "*" {
return "每小時".into();
}
if min == "0"
&& let Some(rest) = hour.strip_prefix("*/")
&& dom == "*"
&& month == "*"
&& dow == "*"
{
return if rest
.parse::<u32>()
.ok()
.is_some_and(|n| n > 0 && 24 % n == 0)
{
format!("{rest} 小時")
} else {
format!("日曆排程:{expr}")
};
}
if min.parse::<u32>().is_ok() && hour.parse::<u32>().is_ok() && month == "*" {
let at = format!("{hour:0>2}:{min:0>2}");
if dom == "*" && dow == "*" {
return format!("每天 {at}");
}
if dom == "*" && dow == "1-5" {
return format!("工作日 {at}");
}
if dom == "*" && dow == "1" {
return format!("每週一 {at}");
}
if dom == "1" && dow == "*" {
return format!("每月 1 日 {at}");
}
}
expr.to_string()
}
#[cfg(test)]
mod tests {
use super::{describe_cron, next_fire, validate_cron};
use chrono::TimeZone;
#[test]
fn weekday_nine_is_valid() {
assert_eq!(validate_cron("0 9 * * 1-5").unwrap(), "0 9 * * 1-5");
assert!(validate_cron("not cron").is_err());
}
#[test]
fn describes_common_patterns() {
assert_eq!(describe_cron("0 9 * * *"), "每天 09:00");
assert_eq!(describe_cron("0 9 * * 1-5"), "工作日 09:00");
assert_eq!(describe_cron("*/15 * * * *"), "每 15 分鐘");
}
#[test]
fn next_fire_is_in_the_future() {
let from = chrono::Utc.with_ymd_and_hms(2026, 9, 5, 0, 0, 0).unwrap();
let next = next_fire("0 9 * * *", "Asia/Taipei", from).unwrap();
assert!(next > from);
}
}
async fn validate_thread(
state: &AppState,
actor: &Actor,
bot: &str,
thread: Option<&str>,
) -> Result<(), String> {
let owns: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3)",
)
.bind(bot)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_one(state.pool())
.await
.map_err(|e| e.to_string())?;
if !owns {
return Err("bot not found".into());
}
if let Some(id) = thread {
let valid: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE id=$1 AND bot_id=$2 AND status='active' AND room_id IS NULL)")
.bind(id).bind(bot).fetch_one(state.pool()).await.map_err(|e| e.to_string())?;
if !valid {
return Err("schedule conversation must belong to this bot and be active".into());
}
}
Ok(())
}
fn next_after_due(
expr: &str,
timezone: &str,
due: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<DateTime<Utc>, String> {
if let Some(seconds) = interval_seconds(expr)? {
let elapsed = (now - due).num_seconds().max(0);
return Ok(due + chrono::TimeDelta::seconds((elapsed / seconds + 1) * seconds));
}
next_fire(expr, timezone, now)
}
#[cfg(test)]
mod regression_tests {
use super::*;
use chrono::{Datelike, TimeZone};
#[test]
fn intervals_cross_month_and_dst_without_reset() {
let from = Utc.with_ymd_and_hms(2026, 1, 31, 9, 0, 0).unwrap();
assert_eq!(
(next_fire("@every 3d", "America/New_York", from).unwrap() - from).num_hours(),
72
);
let dst = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap();
assert_eq!(
(next_fire("@every 2d", "America/New_York", dst).unwrap() - dst).num_hours(),
48
);
}
#[test]
fn weekday_means_monday_through_friday() {
let friday = Utc.with_ymd_and_hms(2026, 9, 4, 2, 0, 0).unwrap();
let next = next_fire("0 9 * * 1-5", "Asia/Taipei", friday).unwrap();
assert_eq!(next.weekday(), chrono::Weekday::Mon);
}
#[test]
fn invalid_formats_and_timezones_fail_closed() {
for expr in [
"0 0 9 * * 1",
"99 25 * * *",
"@every 0d",
"@every 366h",
"@every 5z",
] {
assert!(validate_cron(expr).is_err(), "{expr}");
}
assert!(validate_timezone("Asia/Typo").is_err());
}
#[test]
fn missed_intervals_preserve_phase() {
let due = Utc.with_ymd_and_hms(2026, 9, 5, 0, 0, 0).unwrap();
assert_eq!(
next_after_due(
"@every 3m",
"UTC",
due,
due + chrono::TimeDelta::seconds(400)
)
.unwrap(),
due + chrono::TimeDelta::seconds(540)
);
}
}