Skip to main content

beam_core/
schedule_parser.rs

1use chrono::{DateTime, NaiveDateTime, Utc};
2
3use crate::schedule_store::{ParsedSchedule, ParsedScheduleKind};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct ParsedNaturalSchedule {
7    pub parsed: ParsedSchedule,
8    pub prompt: String,
9    pub name: String,
10}
11
12fn cron_ps(expr: String, display: String) -> ParsedSchedule {
13    ParsedSchedule {
14        kind: ParsedScheduleKind::Cron,
15        run_at: None,
16        minutes: None,
17        expr: Some(expr),
18        display,
19    }
20}
21
22fn parse_time_hm(input: &str) -> Option<(u32, u32, String)> {
23    let s = input.trim_start();
24    if let Some(idx) = s.find([':', ':']) {
25        let hour = s[..idx].parse().ok()?;
26        let mut end = idx + 1;
27        for (offset, ch) in s[end..].char_indices() {
28            if !ch.is_ascii_digit() {
29                break;
30            }
31            end = idx + 1 + offset + ch.len_utf8();
32        }
33        let minute_str = &s[idx + 1..end];
34        if minute_str.is_empty() {
35            return None;
36        }
37        let minute = minute_str.parse().ok()?;
38        return Some((hour, minute, s[end..].to_string()));
39    }
40
41    if let Some(idx) = s.find('点') {
42        let hour = s[..idx].parse().ok()?;
43        let mut start = idx + '点'.len_utf8();
44        let mut minute_end = start;
45        for (offset, ch) in s[start..].char_indices() {
46            if !ch.is_ascii_digit() {
47                break;
48            }
49            minute_end = start + offset + ch.len_utf8();
50        }
51        if minute_end > start {
52            let minute = s[start..minute_end].parse().ok()?;
53            start = minute_end;
54            if s[start..].starts_with('分') {
55                start += '分'.len_utf8();
56            }
57            return Some((hour, minute, s[start..].to_string()));
58        }
59        return Some((hour, 0, s[start..].to_string()));
60    }
61
62    None
63}
64
65fn parse_chinese_schedule(input: &str) -> Option<(ParsedSchedule, String)> {
66    let s = input.trim();
67    let norm = s.replace(' ', "");
68
69    if let Some(rest) = norm.strip_prefix("每个工作日")
70        && let Some((hour, minute, tail)) = parse_time_hm(rest)
71    {
72        return Some((
73            cron_ps(
74                format!("{minute} {hour} * * 1-5"),
75                format!("工作日 {hour}:{minute:02}"),
76            ),
77            tail,
78        ));
79    }
80    if let Some(rest) = norm.strip_prefix("工作日每天")
81        && let Some((hour, minute, tail)) = parse_time_hm(rest)
82    {
83        return Some((
84            cron_ps(
85                format!("{minute} {hour} * * 1-5"),
86                format!("工作日 {hour}:{minute:02}"),
87            ),
88            tail,
89        ));
90    }
91    if let Some(rest) = norm
92        .strip_prefix("每天")
93        .or_else(|| norm.strip_prefix("每日"))
94        && let Some((hour, minute, tail)) = parse_time_hm(rest)
95    {
96        return Some((
97            cron_ps(
98                format!("{minute} {hour} * * *"),
99                format!("每天 {hour}:{minute:02}"),
100            ),
101            tail,
102        ));
103    }
104    if let Some(rest) = norm.strip_prefix("每周") {
105        let mut chars = rest.chars();
106        if let Some(day) = chars.next() {
107            let weekday = match day {
108                '一' => 1,
109                '二' => 2,
110                '三' => 3,
111                '四' => 4,
112                '五' => 5,
113                '六' => 6,
114                '日' | '天' => 0,
115                _ => return None,
116            };
117            let tail = chars.as_str();
118            if let Some((hour, minute, tail2)) = parse_time_hm(tail) {
119                return Some((
120                    cron_ps(
121                        format!("{minute} {hour} * * {weekday}"),
122                        format!("每周{day} {hour}:{minute:02}"),
123                    ),
124                    tail2,
125                ));
126            }
127        }
128    }
129    if let Some(rest) = norm.strip_prefix("每月") {
130        let mut digits = String::new();
131        let mut idx = 0usize;
132        for ch in rest.chars() {
133            if ch.is_ascii_digit() {
134                digits.push(ch);
135                idx += ch.len_utf8();
136            } else {
137                break;
138            }
139        }
140        if !digits.is_empty() {
141            let day: u32 = digits.parse().ok()?;
142            let tail = &rest[idx..];
143            let tail = tail
144                .strip_prefix('号')
145                .or_else(|| tail.strip_prefix('日'))?;
146            if let Some((hour, minute, tail2)) = parse_time_hm(tail) {
147                return Some((
148                    cron_ps(
149                        format!("{minute} {hour} {day} * *"),
150                        format!("每月{day}号 {hour}:{minute:02}"),
151                    ),
152                    tail2,
153                ));
154            }
155        }
156    }
157    if let Some(rest) = norm.strip_prefix("每小时") {
158        return Some((
159            cron_ps("0 * * * *".to_string(), "每小时".to_string()),
160            rest.to_string(),
161        ));
162    }
163    if let Some(rest) = norm.strip_prefix("每") {
164        if let Some(idx) = rest.find('小') {
165            let (n, tail) = rest.split_at(idx);
166            let tail = tail.trim_start_matches("小时");
167            if let Ok(hours) = n.parse::<u64>() {
168                let expr = if hours == 1 {
169                    "0 * * * *".to_string()
170                } else {
171                    format!("0 */{hours} * * *")
172                };
173                return Some((cron_ps(expr, format!("每 {hours} 小时")), tail.to_string()));
174            }
175        }
176        if let Some(idx) = rest.find('分') {
177            let (n, tail) = rest.split_at(idx);
178            let tail = tail.trim_start_matches("分钟");
179            if let Ok(minutes) = n.parse::<u64>() {
180                return Some((
181                    cron_ps(format!("*/{minutes} * * * *"), format!("每 {minutes} 分钟")),
182                    tail.to_string(),
183                ));
184            }
185        }
186    }
187    if let Some(rest) = norm.strip_suffix("分钟后")
188        && let Ok(minutes) = rest.parse::<u64>()
189    {
190        let run_at = (Utc::now() + chrono::Duration::minutes(minutes as i64)).to_rfc3339();
191        return Some((
192            ParsedSchedule {
193                kind: ParsedScheduleKind::Once,
194                run_at: Some(run_at),
195                minutes: None,
196                expr: None,
197                display: format!("{minutes} 分钟后"),
198            },
199            String::new(),
200        ));
201    }
202    if let Some(rest) = norm.strip_suffix("小时后")
203        && let Ok(hours) = rest.parse::<u64>()
204    {
205        let run_at = (Utc::now() + chrono::Duration::hours(hours as i64)).to_rfc3339();
206        return Some((
207            ParsedSchedule {
208                kind: ParsedScheduleKind::Once,
209                run_at: Some(run_at),
210                minutes: None,
211                expr: None,
212                display: format!("{hours} 小时后"),
213            },
214            String::new(),
215        ));
216    }
217    if let Some(rest) = norm.strip_prefix("明天")
218        && let Some((hour, minute, tail)) = parse_time_hm(rest)
219    {
220        let tomorrow = Utc::now().date_naive().succ_opt()?;
221        let naive = NaiveDateTime::new(tomorrow, chrono::NaiveTime::from_hms_opt(hour, minute, 0)?);
222        let run_at = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc).to_rfc3339();
223        return Some((
224            ParsedSchedule {
225                kind: ParsedScheduleKind::Once,
226                run_at: Some(run_at),
227                minutes: None,
228                expr: None,
229                display: format!("明天 {hour}:{minute:02}"),
230            },
231            tail,
232        ));
233    }
234    None
235}
236
237fn parse_duration(input: &str) -> Option<ParsedSchedule> {
238    let s = input.trim();
239    let split = s.split_whitespace().collect::<Vec<_>>();
240    if split.len() == 1 {
241        let token = split[0];
242        let mut digits = String::new();
243        let mut suffix = String::new();
244        for ch in token.chars() {
245            if ch.is_ascii_digit() {
246                digits.push(ch);
247            } else {
248                suffix.push(ch);
249            }
250        }
251        if digits.is_empty() || suffix.is_empty() {
252            return None;
253        }
254        let minutes = duration_to_minutes(&digits, &suffix)?;
255        let run_at = (Utc::now() + chrono::Duration::minutes(minutes as i64)).to_rfc3339();
256        return Some(ParsedSchedule {
257            kind: ParsedScheduleKind::Once,
258            run_at: Some(run_at),
259            minutes: None,
260            expr: None,
261            display: format!("once in {s}"),
262        });
263    }
264
265    if split.len() >= 2 && split[0].eq_ignore_ascii_case("every") {
266        let num = split[1];
267        let unit = split.get(2).copied().unwrap_or("m");
268        if let Some(minutes) = duration_to_minutes(num, unit) {
269            return Some(ParsedSchedule {
270                kind: ParsedScheduleKind::Interval,
271                run_at: None,
272                minutes: Some(minutes),
273                expr: None,
274                display: format!("every {minutes}m"),
275            });
276        }
277    }
278    None
279}
280
281fn duration_to_minutes(num_str: &str, unit: &str) -> Option<u64> {
282    let n = num_str.parse::<u64>().ok()?;
283    let u = unit.to_ascii_lowercase();
284    let mult = match u.chars().next()? {
285        'm' => 1,
286        'h' => 60,
287        'd' => 1440,
288        _ => return None,
289    };
290    Some(n * mult)
291}
292
293fn parse_cron(input: &str) -> Option<ParsedSchedule> {
294    let s = input.trim();
295    let parts: Vec<_> = s.split_whitespace().collect();
296    if parts.len() == 5
297        && parts.iter().all(|p| {
298            p.chars()
299                .all(|c| c.is_ascii_digit() || matches!(c, '*' | '-' | ',' | '/'))
300        })
301    {
302        return Some(cron_ps(s.to_string(), s.to_string()));
303    }
304    None
305}
306
307fn parse_iso(input: &str) -> Option<ParsedSchedule> {
308    let s = input.trim();
309    if !s.starts_with("20") && !s.starts_with("19") {
310        return None;
311    }
312    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
313        return Some(ParsedSchedule {
314            kind: ParsedScheduleKind::Once,
315            run_at: Some(dt.with_timezone(&Utc).to_rfc3339()),
316            minutes: None,
317            expr: None,
318            display: format!("once at {}", dt.format("%Y-%m-%d %H:%M:%S")),
319        });
320    }
321    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M") {
322        let dt = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
323        return Some(ParsedSchedule {
324            kind: ParsedScheduleKind::Once,
325            run_at: Some(dt.to_rfc3339()),
326            minutes: None,
327            expr: None,
328            display: format!("once at {}", dt.format("%Y-%m-%d %H:%M:%S")),
329        });
330    }
331    None
332}
333
334pub fn parse_schedule(input: &str) -> Result<ParsedSchedule, String> {
335    let s = input.trim();
336    if s.is_empty() {
337        return Err("empty schedule".to_string());
338    }
339    if let Some((parsed, _rest)) = parse_chinese_schedule(s) {
340        return Ok(parsed);
341    }
342    if let Some(parsed) = parse_duration(s) {
343        return Ok(parsed);
344    }
345    if let Some(parsed) = parse_cron(s) {
346        return Ok(parsed);
347    }
348    if let Some(parsed) = parse_iso(s) {
349        return Ok(parsed);
350    }
351    Err(format!(
352        "invalid schedule '{}'. Use '30m' / 'every 2h' / '0 9 * * *' / '2026-05-01T10:00' / 每日17:50",
353        input
354    ))
355}
356
357pub fn parse_natural_schedule(input: &str) -> Option<ParsedNaturalSchedule> {
358    let s = input.trim();
359    let (parsed, rest) = parse_chinese_schedule(s)?;
360    let mut prompt = rest.trim().trim_start_matches(['给', '帮']);
361    prompt = prompt.trim_start_matches('我').trim();
362    let prompt = prompt.trim_matches(['"', '\'', '「', '」']);
363    if prompt.is_empty() {
364        return None;
365    }
366    let name = if prompt.chars().count() > 20 {
367        let mut out = String::new();
368        for ch in prompt.chars().take(20) {
369            out.push(ch);
370        }
371        out.push_str("...");
372        out
373    } else {
374        prompt.to_string()
375    };
376    Some(ParsedNaturalSchedule {
377        parsed,
378        prompt: prompt.to_string(),
379        name,
380    })
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn parse_cron_schedule() {
389        let parsed = parse_schedule("0 9 * * *").expect("cron");
390        assert_eq!(parsed.kind, ParsedScheduleKind::Cron);
391        assert_eq!(parsed.expr.as_deref(), Some("0 9 * * *"));
392    }
393
394    #[test]
395    fn parse_chinese_schedule_prompt() {
396        let parsed = parse_natural_schedule("每日17:50 帮我看看AI新闻").expect("natural");
397        assert_eq!(parsed.parsed.kind, ParsedScheduleKind::Cron);
398        assert!(!parsed.prompt.is_empty());
399    }
400}