autumn-web 0.3.0

An opinionated, convention-over-configuration web framework for Rust
Documentation
//! Scheduled task infrastructure.
//!
//! Provides [`TaskInfo`] and [`Schedule`] types used by the `#[scheduled]`
//! macro and `tasks![]` collection macro.
//!
//! Tasks are registered via [`AppBuilder::tasks`](crate::app::AppBuilder::tasks)
//! and run alongside the HTTP server using `tokio-cron-scheduler`.

use std::future::Future;
use std::pin::Pin;
use std::time::Duration;

use crate::state::AppState;

/// Handler function type for scheduled tasks.
pub type TaskHandler =
    fn(AppState) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send>>;

/// Metadata for a scheduled task, generated by the `#[scheduled]` macro.
pub struct TaskInfo {
    /// Human-readable task name (for logging and health checks).
    pub name: String,
    /// When/how often to run.
    pub schedule: Schedule,
    /// The task handler, invoked with a clone of `AppState` each run.
    pub handler: TaskHandler,
}

/// How a scheduled task is triggered.
#[non_exhaustive]
pub enum Schedule {
    /// Run after a fixed delay from the end of the previous run.
    FixedDelay(Duration),
    /// Run on a cron schedule (6-field: sec min hour day month weekday).
    Cron {
        /// The 6-field cron expression (e.g., `"0 * * * * *"` for every minute).
        expression: String,
        /// The timezone for the cron expression (e.g., `"America/New_York"`).
        timezone: Option<String>,
    },
}

impl std::fmt::Display for Schedule {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::FixedDelay(d) => write!(f, "every {}s", d.as_secs()),
            Self::Cron { expression, .. } => write!(f, "cron {expression}"),
        }
    }
}

/// Parse a human-readable duration string like `"5m"`, `"1h 30m"`.
///
/// Supported units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days).
///
/// # Errors
///
/// Returns `None` if the string contains invalid syntax.
#[must_use]
pub fn parse_duration(s: &str) -> Option<Duration> {
    let mut total_secs = 0u64;
    let mut current_num = String::new();

    for ch in s.chars() {
        if ch.is_ascii_digit() {
            current_num.push(ch);
        } else if ch.is_ascii_alphabetic() {
            let num: u64 = current_num.parse().ok()?;
            current_num.clear();
            match ch {
                's' => total_secs = total_secs.checked_add(num)?,
                'm' => total_secs = total_secs.checked_add(num.checked_mul(60)?)?,
                'h' => total_secs = total_secs.checked_add(num.checked_mul(3600)?)?,
                'd' => total_secs = total_secs.checked_add(num.checked_mul(86400)?)?,
                _ => return None,
            }
        } else if ch == ' ' {
            // Skip spaces between components
        } else {
            return None;
        }
    }

    if !current_num.is_empty() {
        return None; // Trailing number without unit
    }

    if total_secs == 0 {
        return None;
    }

    Some(Duration::from_secs(total_secs))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_seconds() {
        assert_eq!(parse_duration("5s"), Some(Duration::from_secs(5)));
    }

    #[test]
    fn parse_minutes() {
        assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
    }

    #[test]
    fn parse_hours() {
        assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
    }

    #[test]
    fn parse_compound() {
        assert_eq!(parse_duration("1h 30m"), Some(Duration::from_secs(5400)));
    }

    #[test]
    fn parse_day() {
        assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86400)));
    }

    #[test]
    fn invalid_unit() {
        assert!(parse_duration("5x").is_none());
    }

    #[test]
    fn trailing_number() {
        assert!(parse_duration("5").is_none());
    }

    #[test]
    fn empty() {
        assert!(parse_duration("").is_none());
    }

    #[test]
    fn zero_duration() {
        assert!(parse_duration("0s").is_none());
        assert!(parse_duration("0m").is_none());
    }

    #[test]
    fn invalid_characters() {
        assert!(parse_duration("1h_30m").is_none());
        assert!(parse_duration("1h-30m").is_none());
    }

    #[test]
    fn multiple_spaces() {
        assert_eq!(parse_duration("1h   30m"), Some(Duration::from_secs(5400)));
    }

    #[test]
    fn compound_trailing_number() {
        assert!(parse_duration("1h 30").is_none());
    }
}

#[cfg(test)]
mod havoc_proptests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn parse_duration_fuzz_panic(s in "[0-9]{15,30}[smhd]") {
            let _ = parse_duration(&s);
        }
    }
}