Skip to main content

max / goingson

17.9 KB · 475 lines History Blame Raw
1 //! Natural language parser for quick-add task input.
2
3 use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Utc};
4 use crate::constants::{APPROXIMATE_DAYS_PER_MONTH, DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, MAX_RELATIVE_DATE_DAYS};
5 use crate::models::{Priority, Recurrence};
6
7 /// Parsed task data from quick-add input.
8 #[derive(Debug, Default, Clone)]
9 pub struct ParsedTask {
10 /// The task description (everything not recognized as a modifier).
11 pub description: String,
12 /// Project name if specified via `project:` or `proj:`.
13 pub project_name: Option<String>,
14 /// Priority if specified via `priority:` or `pri:`.
15 pub priority: Option<Priority>,
16 /// Due date if specified via `due:`.
17 pub due: Option<DateTime<Utc>>,
18 /// Recurrence if specified via `recur:` or `rec:`.
19 pub recurrence: Option<Recurrence>,
20 /// Tags specified via `+tagname`.
21 pub tags: Vec<String>,
22 }
23
24 /// Result of parsing quick-add input, including any warnings.
25 #[derive(Debug, Clone)]
26 pub struct ParseResult {
27 /// The parsed task data.
28 pub task: ParsedTask,
29 /// Non-fatal parsing issues (e.g., unrecognized date format).
30 pub warnings: Vec<String>,
31 }
32
33 impl ParseResult {
34 /// Returns true if there are no warnings.
35 pub fn is_clean(&self) -> bool {
36 self.warnings.is_empty()
37 }
38 }
39
40 /// Parse a quick-add string into task components.
41 ///
42 /// This is a convenience wrapper around [`parse_quick_add_with_warnings`] that
43 /// discards warnings. Use [`parse_quick_add_with_warnings`] if you need to
44 /// report parsing issues to the user.
45 ///
46 /// # Supported syntax
47 ///
48 /// - `+tag` - Add a tag (can have multiple)
49 /// - `project:Name` or `proj:Name` - Link to project by name
50 /// - `priority:H` or `pri:H` or `priority:high` - Set priority (H/M/L or high/medium/low)
51 /// - `due:tomorrow` or `due:2026-02-15` or `due:mon` - Set due date
52 /// - `recur:daily` or `recur:weekly` or `recur:monthly` - Set recurrence
53 ///
54 /// Everything else becomes part of the description.
55 ///
56 /// # Example
57 ///
58 /// ```rust
59 /// use goingson_core::parse_quick_add;
60 ///
61 /// let task = parse_quick_add("Buy groceries +shopping due:tomorrow pri:H");
62 /// assert_eq!(task.description, "Buy groceries");
63 /// assert_eq!(task.tags, vec!["shopping"]);
64 /// ```
65 pub fn parse_quick_add(input: &str) -> ParsedTask {
66 parse_quick_add_with_warnings(input).task
67 }
68
69 /// Parse a quick-add string into task components, collecting warnings.
70 ///
71 /// Returns a [`ParseResult`] containing the parsed task and any warnings
72 /// generated during parsing (e.g., unrecognized date formats).
73 pub fn parse_quick_add_with_warnings(input: &str) -> ParseResult {
74 let mut task = ParsedTask::default();
75 let mut warnings = Vec::new();
76 let mut description_parts = Vec::new();
77
78 for token in input.split_whitespace() {
79 if let Some(tag) = token.strip_prefix('+') {
80 // Tag: +tagname
81 if !tag.is_empty() {
82 task.tags.push(tag.to_string());
83 }
84 } else if let Some(rest) = strip_prefix_ci(token, "project:").or_else(|| strip_prefix_ci(token, "proj:")) {
85 // Project: project:Name or proj:Name
86 if !rest.is_empty() {
87 task.project_name = Some(rest.to_string());
88 }
89 } else if let Some(rest) = strip_prefix_ci(token, "priority:").or_else(|| strip_prefix_ci(token, "pri:")) {
90 // Priority: priority:H or pri:high
91 match parse_priority(rest) {
92 Some(p) => task.priority = Some(p),
93 None => warnings.push(format!("Unrecognized priority: '{}'", rest)),
94 }
95 } else if let Some(rest) = strip_prefix_ci(token, "due:") {
96 // Due date: due:tomorrow, due:2026-02-15, due:mon
97 match parse_due_date(rest) {
98 Some(d) => task.due = Some(d),
99 None => warnings.push(format!("Unrecognized date format: '{}'", rest)),
100 }
101 } else if let Some(rest) = strip_prefix_ci(token, "recur:").or_else(|| strip_prefix_ci(token, "rec:")) {
102 // Recurrence: recur:daily, recur:weekly, recur:monthly
103 match parse_recurrence(rest) {
104 Some(r) => task.recurrence = Some(r),
105 None => warnings.push(format!("Unrecognized recurrence: '{}'", rest)),
106 }
107 } else {
108 // Regular word - part of description
109 description_parts.push(token);
110 }
111 }
112
113 task.description = description_parts.join(" ");
114 ParseResult { task, warnings }
115 }
116
117 use crate::text_utils::strip_prefix_ci;
118
119 /// Parse priority from string
120 fn parse_priority(s: &str) -> Option<Priority> {
121 match s.to_lowercase().as_str() {
122 "h" | "high" => Some(Priority::High),
123 "m" | "medium" | "med" => Some(Priority::Medium),
124 "l" | "low" => Some(Priority::Low),
125 _ => None,
126 }
127 }
128
129 /// Parse due date from various formats
130 fn parse_due_date(s: &str) -> Option<DateTime<Utc>> {
131 let s_lower = s.to_lowercase();
132 let today = Utc::now().date_naive();
133 let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?;
134
135 match s_lower.as_str() {
136 "today" | "tod" => {
137 let dt = today.and_time(default_time);
138 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
139 }
140 "tomorrow" | "tom" => {
141 let dt = (today + Duration::days(1)).and_time(default_time);
142 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
143 }
144 "yesterday" | "yes" => {
145 let dt = (today - Duration::days(1)).and_time(default_time);
146 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
147 }
148 // Day names - find next occurrence
149 "monday" | "mon" => next_weekday(today, chrono::Weekday::Mon, default_time),
150 "tuesday" | "tue" => next_weekday(today, chrono::Weekday::Tue, default_time),
151 "wednesday" | "wed" => next_weekday(today, chrono::Weekday::Wed, default_time),
152 "thursday" | "thu" => next_weekday(today, chrono::Weekday::Thu, default_time),
153 "friday" | "fri" => next_weekday(today, chrono::Weekday::Fri, default_time),
154 "saturday" | "sat" => next_weekday(today, chrono::Weekday::Sat, default_time),
155 "sunday" | "sun" => next_weekday(today, chrono::Weekday::Sun, default_time),
156 // Relative days: +1d, +2w, +1m
157 _ if s_lower.starts_with('+') => parse_relative_date(&s_lower[1..], today, default_time),
158 // ISO date format: 2026-02-15
159 _ => {
160 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
161 let dt = date.and_time(default_time);
162 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
163 } else {
164 None
165 }
166 }
167 }
168 }
169
170 /// Find the next occurrence of a weekday
171 fn next_weekday(from: NaiveDate, target: chrono::Weekday, time: NaiveTime) -> Option<DateTime<Utc>> {
172 use chrono::Datelike;
173
174 let current = from.weekday();
175 let current_num = current.num_days_from_monday();
176 let target_num = target.num_days_from_monday();
177
178 let days_ahead = if target_num > current_num {
179 target_num - current_num
180 } else if target_num < current_num {
181 7 - (current_num - target_num)
182 } else {
183 7 // Same day, go to next week
184 };
185
186 let dt = (from + Duration::days(days_ahead as i64)).and_time(time);
187 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
188 }
189
190 /// Parse relative date like "1d", "2w", "1m"
191 fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<DateTime<Utc>> {
192 if s.is_empty() {
193 return None;
194 }
195
196 // Split off the last char (the unit) using char-boundary-safe indexing so a
197 // trailing multibyte char (e.g. "1😀") returns None instead of panicking.
198 let (unit_idx, unit) = s.char_indices().next_back()?;
199 let num_str = &s[..unit_idx];
200 let num: i64 = num_str.parse().ok()?;
201 if !(0..=MAX_RELATIVE_DATE_DAYS).contains(&num) {
202 return None;
203 }
204
205 let target = match unit {
206 'd' => from + Duration::days(num),
207 'w' => from + Duration::weeks(num),
208 'm' => {
209 // Approximate month
210 from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH)
211 }
212 _ => return None,
213 };
214
215 let dt = target.and_time(time);
216 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
217 }
218
219 /// Parse recurrence from string
220 fn parse_recurrence(s: &str) -> Option<Recurrence> {
221 match s.to_lowercase().as_str() {
222 "daily" | "d" | "day" => Some(Recurrence::Daily),
223 "weekly" | "w" | "week" => Some(Recurrence::Weekly),
224 "monthly" | "m" | "month" => Some(Recurrence::Monthly),
225 "none" | "n" => Some(Recurrence::None),
226 _ => None,
227 }
228 }
229
230 #[cfg(test)]
231 mod tests {
232 use super::*;
233 use chrono::Datelike;
234
235 #[test]
236 fn test_simple_description() {
237 let result = parse_quick_add("Buy groceries");
238 assert_eq!(result.description, "Buy groceries");
239 assert!(result.tags.is_empty());
240 assert!(result.priority.is_none());
241 }
242
243 #[test]
244 fn test_with_tags() {
245 let result = parse_quick_add("Buy groceries +shopping +errands");
246 assert_eq!(result.description, "Buy groceries");
247 assert_eq!(result.tags, vec!["shopping", "errands"]);
248 }
249
250 #[test]
251 fn test_with_priority() {
252 let result = parse_quick_add("Important task pri:H");
253 assert_eq!(result.description, "Important task");
254 assert_eq!(result.priority, Some(Priority::High));
255
256 let result2 = parse_quick_add("Medium task priority:medium");
257 assert_eq!(result2.priority, Some(Priority::Medium));
258 }
259
260 #[test]
261 fn test_with_project() {
262 let result = parse_quick_add("Fix bug project:Website");
263 assert_eq!(result.description, "Fix bug");
264 assert_eq!(result.project_name, Some("Website".to_string()));
265 }
266
267 #[test]
268 fn test_with_recurrence() {
269 let result = parse_quick_add("Daily standup recur:daily");
270 assert_eq!(result.description, "Daily standup");
271 assert_eq!(result.recurrence, Some(Recurrence::Daily));
272
273 let result2 = parse_quick_add("Weekly review rec:weekly");
274 assert_eq!(result2.recurrence, Some(Recurrence::Weekly));
275 }
276
277 #[test]
278 fn test_with_due_tomorrow() {
279 let result = parse_quick_add("Submit report due:tomorrow");
280 assert_eq!(result.description, "Submit report");
281 assert!(result.due.is_some());
282 // Due should be tomorrow
283 let due = result.due.unwrap();
284 let tomorrow = Utc::now().date_naive() + Duration::days(1);
285 assert_eq!(due.date_naive(), tomorrow);
286 }
287
288 #[test]
289 fn test_complex() {
290 let result = parse_quick_add("Call mom +family +important due:sunday pri:H proj:Personal");
291 assert_eq!(result.description, "Call mom");
292 assert_eq!(result.tags, vec!["family", "important"]);
293 assert_eq!(result.priority, Some(Priority::High));
294 assert_eq!(result.project_name, Some("Personal".to_string()));
295 assert!(result.due.is_some());
296 }
297
298 #[test]
299 fn test_iso_date() {
300 let result = parse_quick_add("Meeting due:2026-03-15");
301 assert!(result.due.is_some());
302 let due = result.due.unwrap();
303 assert_eq!(due.date_naive().to_string(), "2026-03-15");
304 }
305
306 #[test]
307 fn test_relative_date() {
308 let result = parse_quick_add("Task due:+3d");
309 assert!(result.due.is_some());
310 let due = result.due.unwrap();
311 let expected = Utc::now().date_naive() + Duration::days(3);
312 assert_eq!(due.date_naive(), expected);
313 }
314
315 #[test]
316 fn test_relative_weeks() {
317 let result = parse_quick_add("Task due:+2w");
318 assert!(result.due.is_some());
319 let due = result.due.unwrap();
320 let expected = Utc::now().date_naive() + Duration::weeks(2);
321 assert_eq!(due.date_naive(), expected);
322 }
323
324 #[test]
325 fn test_relative_months() {
326 let result = parse_quick_add("Task due:+1m");
327 assert!(result.due.is_some());
328 // Approximate month = 30 days
329 let due = result.due.unwrap();
330 let expected = Utc::now().date_naive() + Duration::days(30);
331 assert_eq!(due.date_naive(), expected);
332 }
333
334 #[test]
335 fn test_priority_variations() {
336 assert_eq!(parse_quick_add("Task pri:h").priority, Some(Priority::High));
337 assert_eq!(parse_quick_add("Task pri:H").priority, Some(Priority::High));
338 assert_eq!(parse_quick_add("Task pri:high").priority, Some(Priority::High));
339 assert_eq!(parse_quick_add("Task pri:HIGH").priority, Some(Priority::High));
340 assert_eq!(parse_quick_add("Task priority:m").priority, Some(Priority::Medium));
341 assert_eq!(parse_quick_add("Task priority:med").priority, Some(Priority::Medium));
342 assert_eq!(parse_quick_add("Task pri:l").priority, Some(Priority::Low));
343 assert_eq!(parse_quick_add("Task pri:low").priority, Some(Priority::Low));
344 }
345
346 #[test]
347 fn test_recurrence_variations() {
348 assert_eq!(parse_quick_add("Task rec:d").recurrence, Some(Recurrence::Daily));
349 assert_eq!(parse_quick_add("Task rec:daily").recurrence, Some(Recurrence::Daily));
350 assert_eq!(parse_quick_add("Task recur:w").recurrence, Some(Recurrence::Weekly));
351 assert_eq!(parse_quick_add("Task recur:week").recurrence, Some(Recurrence::Weekly));
352 assert_eq!(parse_quick_add("Task rec:m").recurrence, Some(Recurrence::Monthly));
353 assert_eq!(parse_quick_add("Task rec:month").recurrence, Some(Recurrence::Monthly));
354 assert_eq!(parse_quick_add("Task rec:n").recurrence, Some(Recurrence::None));
355 }
356
357 #[test]
358 fn test_day_names() {
359 let result = parse_quick_add("Task due:mon");
360 assert!(result.due.is_some());
361 // Should be next Monday
362 let due = result.due.unwrap();
363 assert_eq!(due.weekday(), chrono::Weekday::Mon);
364
365 let result = parse_quick_add("Task due:friday");
366 assert!(result.due.is_some());
367 let due = result.due.unwrap();
368 assert_eq!(due.weekday(), chrono::Weekday::Fri);
369 }
370
371 #[test]
372 fn test_today_and_tomorrow() {
373 let result = parse_quick_add("Task due:today");
374 assert!(result.due.is_some());
375 assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive());
376
377 let result = parse_quick_add("Task due:tod");
378 assert!(result.due.is_some());
379 assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive());
380
381 let result = parse_quick_add("Task due:tom");
382 assert!(result.due.is_some());
383 assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive() + Duration::days(1));
384 }
385
386 #[test]
387 fn test_multiple_tags() {
388 let result = parse_quick_add("+first Task description +second +third");
389 assert_eq!(result.description, "Task description");
390 assert_eq!(result.tags, vec!["first", "second", "third"]);
391 }
392
393 #[test]
394 fn test_empty_input() {
395 let result = parse_quick_add("");
396 assert_eq!(result.description, "");
397 assert!(result.tags.is_empty());
398 assert!(result.priority.is_none());
399 }
400
401 #[test]
402 fn test_only_modifiers() {
403 let result = parse_quick_add("+tag pri:H due:tomorrow");
404 assert_eq!(result.description, "");
405 assert_eq!(result.tags, vec!["tag"]);
406 assert_eq!(result.priority, Some(Priority::High));
407 assert!(result.due.is_some());
408 }
409
410 #[test]
411 fn test_project_with_spaces_not_supported() {
412 // Projects with spaces need quoting or different syntax
413 let result = parse_quick_add("Task proj:My Project");
414 assert_eq!(result.project_name, Some("My".to_string())); // Only gets "My"
415 assert!(result.description.contains("Project")); // "Project" becomes description
416 }
417
418 #[test]
419 fn test_case_insensitive_prefixes() {
420 assert_eq!(parse_quick_add("Task PRI:H").priority, Some(Priority::High));
421 assert!(parse_quick_add("Task DUE:tomorrow").due.is_some());
422 assert_eq!(parse_quick_add("Task PROJECT:Test").project_name, Some("Test".to_string()));
423 assert_eq!(parse_quick_add("Task RECUR:daily").recurrence, Some(Recurrence::Daily));
424 }
425
426 #[test]
427 fn test_warnings_for_invalid_values() {
428 let result = parse_quick_add_with_warnings("Task pri:invalid");
429 assert!(!result.is_clean());
430 assert!(result.warnings.iter().any(|w| w.contains("priority")));
431
432 let result = parse_quick_add_with_warnings("Task due:notadate");
433 assert!(!result.is_clean());
434 assert!(result.warnings.iter().any(|w| w.contains("date")));
435
436 let result = parse_quick_add_with_warnings("Task rec:invalid");
437 assert!(!result.is_clean());
438 assert!(result.warnings.iter().any(|w| w.contains("recurrence")));
439 }
440
441 #[test]
442 fn test_no_warnings_for_valid_input() {
443 let result = parse_quick_add_with_warnings("Fix bug +work pri:H due:tomorrow");
444 assert!(result.is_clean());
445 assert!(result.warnings.is_empty());
446 }
447
448 #[test]
449 fn test_empty_tag_ignored() {
450 let result = parse_quick_add("Task + +valid");
451 // Empty tag after first + should be skipped
452 assert_eq!(result.tags, vec!["valid"]);
453 }
454
455 #[test]
456 fn test_strip_prefix_ci_helper() {
457 assert_eq!(strip_prefix_ci("PROJECT:test", "project:"), Some("test"));
458 assert_eq!(strip_prefix_ci("Project:test", "project:"), Some("test"));
459 assert_eq!(strip_prefix_ci("proj:test", "project:"), None);
460 assert_eq!(strip_prefix_ci("pr", "project:"), None);
461 }
462
463 #[test]
464 fn test_due_relative_multibyte_does_not_panic() {
465 // Regression: a trailing multibyte char in a +Nunit relative date used to
466 // panic in split_at(len-1). Relative dates are reached only via a leading '+'.
467 for input in ["Task due:+1😀", "Task due:+3€", "Task due:+😀", "Task due:+1d"] {
468 let _ = parse_quick_add(input);
469 }
470 // The multibyte unit is unrecognized → no due; "+1d" is valid → due set.
471 assert!(parse_quick_add("Task due:+1😀").due.is_none());
472 assert!(parse_quick_add("Task due:+1d").due.is_some());
473 }
474 }
475