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}