1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
//! 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.
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>,
},
}
/// 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);
}
}
}