Skip to main content

max / goingson

17.1 KB · 460 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 let (num_str, unit) = s.split_at(s.len() - 1);
197 let num: i64 = num_str.parse().ok()?;
198 if num < 0 || num > MAX_RELATIVE_DATE_DAYS {
199 return None;
200 }
201
202 let target = match unit {
203 "d" => from + Duration::days(num),
204 "w" => from + Duration::weeks(num),
205 "m" => {
206 // Approximate month
207 from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH)
208 }
209 _ => return None,
210 };
211
212 let dt = target.and_time(time);
213 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
214 }
215
216 /// Parse recurrence from string
217 fn parse_recurrence(s: &str) -> Option<Recurrence> {
218 match s.to_lowercase().as_str() {
219 "daily" | "d" | "day" => Some(Recurrence::Daily),
220 "weekly" | "w" | "week" => Some(Recurrence::Weekly),
221 "monthly" | "m" | "month" => Some(Recurrence::Monthly),
222 "none" | "n" => Some(Recurrence::None),
223 _ => None,
224 }
225 }
226
227 #[cfg(test)]
228 mod tests {
229 use super::*;
230 use chrono::Datelike;
231
232 #[test]
233 fn test_simple_description() {
234 let result = parse_quick_add("Buy groceries");
235 assert_eq!(result.description, "Buy groceries");
236 assert!(result.tags.is_empty());
237 assert!(result.priority.is_none());
238 }
239
240 #[test]
241 fn test_with_tags() {
242 let result = parse_quick_add("Buy groceries +shopping +errands");
243 assert_eq!(result.description, "Buy groceries");
244 assert_eq!(result.tags, vec!["shopping", "errands"]);
245 }
246
247 #[test]
248 fn test_with_priority() {
249 let result = parse_quick_add("Important task pri:H");
250 assert_eq!(result.description, "Important task");
251 assert_eq!(result.priority, Some(Priority::High));
252
253 let result2 = parse_quick_add("Medium task priority:medium");
254 assert_eq!(result2.priority, Some(Priority::Medium));
255 }
256
257 #[test]
258 fn test_with_project() {
259 let result = parse_quick_add("Fix bug project:Claudetainment");
260 assert_eq!(result.description, "Fix bug");
261 assert_eq!(result.project_name, Some("Claudetainment".to_string()));
262 }
263
264 #[test]
265 fn test_with_recurrence() {
266 let result = parse_quick_add("Daily standup recur:daily");
267 assert_eq!(result.description, "Daily standup");
268 assert_eq!(result.recurrence, Some(Recurrence::Daily));
269
270 let result2 = parse_quick_add("Weekly review rec:weekly");
271 assert_eq!(result2.recurrence, Some(Recurrence::Weekly));
272 }
273
274 #[test]
275 fn test_with_due_tomorrow() {
276 let result = parse_quick_add("Submit report due:tomorrow");
277 assert_eq!(result.description, "Submit report");
278 assert!(result.due.is_some());
279 // Due should be tomorrow
280 let due = result.due.unwrap();
281 let tomorrow = Utc::now().date_naive() + Duration::days(1);
282 assert_eq!(due.date_naive(), tomorrow);
283 }
284
285 #[test]
286 fn test_complex() {
287 let result = parse_quick_add("Call mom +family +important due:sunday pri:H proj:Personal");
288 assert_eq!(result.description, "Call mom");
289 assert_eq!(result.tags, vec!["family", "important"]);
290 assert_eq!(result.priority, Some(Priority::High));
291 assert_eq!(result.project_name, Some("Personal".to_string()));
292 assert!(result.due.is_some());
293 }
294
295 #[test]
296 fn test_iso_date() {
297 let result = parse_quick_add("Meeting due:2026-03-15");
298 assert!(result.due.is_some());
299 let due = result.due.unwrap();
300 assert_eq!(due.date_naive().to_string(), "2026-03-15");
301 }
302
303 #[test]
304 fn test_relative_date() {
305 let result = parse_quick_add("Task due:+3d");
306 assert!(result.due.is_some());
307 let due = result.due.unwrap();
308 let expected = Utc::now().date_naive() + Duration::days(3);
309 assert_eq!(due.date_naive(), expected);
310 }
311
312 #[test]
313 fn test_relative_weeks() {
314 let result = parse_quick_add("Task due:+2w");
315 assert!(result.due.is_some());
316 let due = result.due.unwrap();
317 let expected = Utc::now().date_naive() + Duration::weeks(2);
318 assert_eq!(due.date_naive(), expected);
319 }
320
321 #[test]
322 fn test_relative_months() {
323 let result = parse_quick_add("Task due:+1m");
324 assert!(result.due.is_some());
325 // Approximate month = 30 days
326 let due = result.due.unwrap();
327 let expected = Utc::now().date_naive() + Duration::days(30);
328 assert_eq!(due.date_naive(), expected);
329 }
330
331 #[test]
332 fn test_priority_variations() {
333 assert_eq!(parse_quick_add("Task pri:h").priority, Some(Priority::High));
334 assert_eq!(parse_quick_add("Task pri:H").priority, Some(Priority::High));
335 assert_eq!(parse_quick_add("Task pri:high").priority, Some(Priority::High));
336 assert_eq!(parse_quick_add("Task pri:HIGH").priority, Some(Priority::High));
337 assert_eq!(parse_quick_add("Task priority:m").priority, Some(Priority::Medium));
338 assert_eq!(parse_quick_add("Task priority:med").priority, Some(Priority::Medium));
339 assert_eq!(parse_quick_add("Task pri:l").priority, Some(Priority::Low));
340 assert_eq!(parse_quick_add("Task pri:low").priority, Some(Priority::Low));
341 }
342
343 #[test]
344 fn test_recurrence_variations() {
345 assert_eq!(parse_quick_add("Task rec:d").recurrence, Some(Recurrence::Daily));
346 assert_eq!(parse_quick_add("Task rec:daily").recurrence, Some(Recurrence::Daily));
347 assert_eq!(parse_quick_add("Task recur:w").recurrence, Some(Recurrence::Weekly));
348 assert_eq!(parse_quick_add("Task recur:week").recurrence, Some(Recurrence::Weekly));
349 assert_eq!(parse_quick_add("Task rec:m").recurrence, Some(Recurrence::Monthly));
350 assert_eq!(parse_quick_add("Task rec:month").recurrence, Some(Recurrence::Monthly));
351 assert_eq!(parse_quick_add("Task rec:n").recurrence, Some(Recurrence::None));
352 }
353
354 #[test]
355 fn test_day_names() {
356 let result = parse_quick_add("Task due:mon");
357 assert!(result.due.is_some());
358 // Should be next Monday
359 let due = result.due.unwrap();
360 assert_eq!(due.weekday(), chrono::Weekday::Mon);
361
362 let result = parse_quick_add("Task due:friday");
363 assert!(result.due.is_some());
364 let due = result.due.unwrap();
365 assert_eq!(due.weekday(), chrono::Weekday::Fri);
366 }
367
368 #[test]
369 fn test_today_and_tomorrow() {
370 let result = parse_quick_add("Task due:today");
371 assert!(result.due.is_some());
372 assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive());
373
374 let result = parse_quick_add("Task due:tod");
375 assert!(result.due.is_some());
376 assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive());
377
378 let result = parse_quick_add("Task due:tom");
379 assert!(result.due.is_some());
380 assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive() + Duration::days(1));
381 }
382
383 #[test]
384 fn test_multiple_tags() {
385 let result = parse_quick_add("+first Task description +second +third");
386 assert_eq!(result.description, "Task description");
387 assert_eq!(result.tags, vec!["first", "second", "third"]);
388 }
389
390 #[test]
391 fn test_empty_input() {
392 let result = parse_quick_add("");
393 assert_eq!(result.description, "");
394 assert!(result.tags.is_empty());
395 assert!(result.priority.is_none());
396 }
397
398 #[test]
399 fn test_only_modifiers() {
400 let result = parse_quick_add("+tag pri:H due:tomorrow");
401 assert_eq!(result.description, "");
402 assert_eq!(result.tags, vec!["tag"]);
403 assert_eq!(result.priority, Some(Priority::High));
404 assert!(result.due.is_some());
405 }
406
407 #[test]
408 fn test_project_with_spaces_not_supported() {
409 // Projects with spaces need quoting or different syntax
410 let result = parse_quick_add("Task proj:My Project");
411 assert_eq!(result.project_name, Some("My".to_string())); // Only gets "My"
412 assert!(result.description.contains("Project")); // "Project" becomes description
413 }
414
415 #[test]
416 fn test_case_insensitive_prefixes() {
417 assert_eq!(parse_quick_add("Task PRI:H").priority, Some(Priority::High));
418 assert!(parse_quick_add("Task DUE:tomorrow").due.is_some());
419 assert_eq!(parse_quick_add("Task PROJECT:Test").project_name, Some("Test".to_string()));
420 assert_eq!(parse_quick_add("Task RECUR:daily").recurrence, Some(Recurrence::Daily));
421 }
422
423 #[test]
424 fn test_warnings_for_invalid_values() {
425 let result = parse_quick_add_with_warnings("Task pri:invalid");
426 assert!(!result.is_clean());
427 assert!(result.warnings.iter().any(|w| w.contains("priority")));
428
429 let result = parse_quick_add_with_warnings("Task due:notadate");
430 assert!(!result.is_clean());
431 assert!(result.warnings.iter().any(|w| w.contains("date")));
432
433 let result = parse_quick_add_with_warnings("Task rec:invalid");
434 assert!(!result.is_clean());
435 assert!(result.warnings.iter().any(|w| w.contains("recurrence")));
436 }
437
438 #[test]
439 fn test_no_warnings_for_valid_input() {
440 let result = parse_quick_add_with_warnings("Fix bug +work pri:H due:tomorrow");
441 assert!(result.is_clean());
442 assert!(result.warnings.is_empty());
443 }
444
445 #[test]
446 fn test_empty_tag_ignored() {
447 let result = parse_quick_add("Task + +valid");
448 // Empty tag after first + should be skipped
449 assert_eq!(result.tags, vec!["valid"]);
450 }
451
452 #[test]
453 fn test_strip_prefix_ci_helper() {
454 assert_eq!(strip_prefix_ci("PROJECT:test", "project:"), Some("test"));
455 assert_eq!(strip_prefix_ci("Project:test", "project:"), Some("test"));
456 assert_eq!(strip_prefix_ci("proj:test", "project:"), None);
457 assert_eq!(strip_prefix_ci("pr", "project:"), None);
458 }
459 }
460