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