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}