use thiserror::Error;
use time::OffsetDateTime;
const MOTIVO_DE_LA_VACIA: &str = "la expresión estaba vacía";
#[derive(Debug)]
pub struct Agenda {
normalizada: String,
analizada: croner::Cron,
}
impl Agenda {
pub fn normalizada(&self) -> &str {
&self.normalizada
}
pub fn proxima_tras(&self, desde: OffsetDateTime) -> Option<OffsetDateTime> {
let instante =
chrono::DateTime::from_timestamp(desde.unix_timestamp(), desde.nanosecond())?;
let siguiente = self.analizada.find_next_occurrence(&instante, false).ok()?;
OffsetDateTime::from_unix_timestamp(siguiente.timestamp()).ok()
}
}
pub fn normalizar(expresion: &str) -> String {
let campos: Vec<&str> = expresion.split_whitespace().collect();
if campos.len() == 5 {
format!("0 {}", campos.join(" "))
} else {
expresion.to_owned()
}
}
pub fn validar(expresion: &str) -> Result<Agenda, ErrorDeExpresion> {
if expresion.trim().is_empty() {
return Err(ErrorDeExpresion::Vacia);
}
let normalizada = normalizar(expresion);
let analizador = croner::parser::CronParser::builder()
.seconds(croner::parser::Seconds::Required)
.dom_and_dow(true)
.build();
let analizada =
analizador
.parse(&normalizada)
.map_err(|error| ErrorDeExpresion::NoParseable {
expresion: expresion.to_owned(),
motivo: error.to_string(),
})?;
Ok(Agenda {
normalizada,
analizada,
})
}
#[derive(Debug, Error)]
pub enum ErrorDeExpresion {
#[error("{}", MOTIVO_DE_LA_VACIA)]
Vacia,
#[error("la expresión cron «{expresion}» no se puede interpretar: {motivo}")]
NoParseable {
expresion: String,
motivo: String,
},
}
impl ErrorDeExpresion {
pub fn motivo(&self) -> &str {
match self {
Self::Vacia => MOTIVO_DE_LA_VACIA,
Self::NoParseable { motivo, .. } => motivo,
}
}
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::pin::Pin;
use time::macros::datetime;
use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError, JobToRunAsync};
use uuid::Uuid;
use super::{ErrorDeExpresion, normalizar, validar};
#[test]
fn la_normalizacion_antepone_el_segundo_solo_a_las_de_exactamente_cinco_campos() {
assert_eq!(
normalizar("0 3 * * *"),
"0 0 3 * * *",
"una expresión de cinco campos tiene que salir con el segundo delante"
);
assert_eq!(
normalizar("0 0 3 * * *"),
"0 0 3 * * *",
"una de seis campos no se toca: ya trae su segundo"
);
assert_eq!(
normalizar("0 0 3 * * * 2027"),
"0 0 3 * * * 2027",
"una de siete campos —con año— es válida y no se reescribe"
);
}
#[test]
fn una_expresion_de_cinco_campos_es_valida_y_calcula_su_proxima_ejecucion_en_utc() {
let agenda = validar("0 3 * * *").expect("la forma de cinco campos tiene que aceptarse");
let base = datetime!(2023-11-14 22:13:20 UTC);
let esperada = datetime!(2023-11-15 03:00:00 UTC);
assert_eq!(
agenda.proxima_tras(base),
Some(esperada),
"la próxima ejecución no es la de la agenda en UTC"
);
}
#[test]
fn la_forma_de_cinco_campos_que_se_acepta_es_la_que_el_reloj_sabe_leer() {
let agenda = validar("0 3 * * *").expect("la forma de cinco campos tiene que aceptarse");
let disparo: Box<JobToRunAsync> = Box::new(
|_job_id: Uuid, _reloj: JobScheduler| -> Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(async move {})
},
);
match Job::new_async(agenda.normalizada(), disparo) {
Ok(_) => {}
Err(JobSchedulerError::ParseSchedule) => panic!(
"el reloj no acepta la forma que la validación dio por buena: esa tarea no \
correría nunca"
),
Err(otro) => panic!("el reloj rechazó la forma validada por otro motivo: {otro:?}"),
}
}
#[test]
fn una_expresion_de_seis_campos_tambien_es_valida_y_da_la_misma_proxima_ejecucion() {
let de_cinco = validar("0 3 * * *").expect("la forma de cinco campos es válida");
let de_seis = validar("0 0 3 * * *").expect("la forma de seis campos también es válida");
let base = datetime!(2023-11-14 22:13:20 UTC);
assert_eq!(
de_cinco.proxima_tras(base),
de_seis.proxima_tras(base),
"las dos formas de la misma agenda no dan la misma próxima ejecución"
);
}
#[test]
fn una_expresion_que_no_parsea_se_rechaza_nombrando_la_expresion_y_el_motivo() {
let expresion = "a b c d e";
let error = validar(expresion)
.expect_err("una expresión que el analizador no entiende tiene que rechazarse");
assert!(
matches!(error, ErrorDeExpresion::NoParseable { .. }),
"el rechazo de una expresión ilegible no es el de «no parsea»: {error}"
);
let mensaje = error.to_string();
assert!(
mensaje.contains(expresion),
"el rechazo no nombra la expresión original: {mensaje}"
);
assert!(
!error.motivo().is_empty(),
"el rechazo no dice el motivo del analizador: {mensaje}"
);
assert!(
mensaje.contains(error.motivo()),
"el motivo no aparece en el mensaje del rechazo: {mensaje}"
);
}
#[test]
fn la_cadena_vacia_se_rechaza_con_el_motivo_de_que_esta_vacia() {
let error = validar("").expect_err("una expresión vacía tiene que rechazarse");
assert!(
matches!(error, ErrorDeExpresion::Vacia),
"el rechazo del vacío no es el del vacío: {error}"
);
assert!(
error.motivo().contains("vacía"),
"el motivo no dice que la expresión está vacía: {error}"
);
assert!(
error.to_string().contains("vacía"),
"el mensaje no dice que la expresión está vacía: {error}"
);
}
}