//! Natural language parser for quick-add task input. use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Utc}; use crate::constants::{APPROXIMATE_DAYS_PER_MONTH, DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, MAX_RELATIVE_DATE_DAYS}; use crate::models::{Priority, Recurrence}; /// Parsed task data from quick-add input. #[derive(Debug, Default, Clone)] pub struct ParsedTask { /// The task description (everything not recognized as a modifier). pub description: String, /// Project name if specified via `project:` or `proj:`. pub project_name: Option, /// Priority if specified via `priority:` or `pri:`. pub priority: Option, /// Due date if specified via `due:`. pub due: Option>, /// Recurrence if specified via `recur:` or `rec:`. pub recurrence: Option, /// Tags specified via `+tagname`. pub tags: Vec, } /// Result of parsing quick-add input, including any warnings. #[derive(Debug, Clone)] pub struct ParseResult { /// The parsed task data. pub task: ParsedTask, /// Non-fatal parsing issues (e.g., unrecognized date format). pub warnings: Vec, } impl ParseResult { /// Returns true if there are no warnings. pub fn is_clean(&self) -> bool { self.warnings.is_empty() } } /// Parse a quick-add string into task components. /// /// This is a convenience wrapper around [`parse_quick_add_with_warnings`] that /// discards warnings. Use [`parse_quick_add_with_warnings`] if you need to /// report parsing issues to the user. /// /// # Supported syntax /// /// - `+tag` - Add a tag (can have multiple) /// - `project:Name` or `proj:Name` - Link to project by name /// - `priority:H` or `pri:H` or `priority:high` - Set priority (H/M/L or high/medium/low) /// - `due:tomorrow` or `due:2026-02-15` or `due:mon` - Set due date /// - `recur:daily` or `recur:weekly` or `recur:monthly` - Set recurrence /// /// Everything else becomes part of the description. /// /// # Example /// /// ```rust /// use goingson_core::parse_quick_add; /// /// let task = parse_quick_add("Buy groceries +shopping due:tomorrow pri:H"); /// assert_eq!(task.description, "Buy groceries"); /// assert_eq!(task.tags, vec!["shopping"]); /// ``` pub fn parse_quick_add(input: &str) -> ParsedTask { parse_quick_add_with_warnings(input).task } /// Parse a quick-add string into task components, collecting warnings. /// /// Returns a [`ParseResult`] containing the parsed task and any warnings /// generated during parsing (e.g., unrecognized date formats). pub fn parse_quick_add_with_warnings(input: &str) -> ParseResult { let mut task = ParsedTask::default(); let mut warnings = Vec::new(); let mut description_parts = Vec::new(); for token in input.split_whitespace() { if let Some(tag) = token.strip_prefix('+') { // Tag: +tagname if !tag.is_empty() { task.tags.push(tag.to_string()); } } else if let Some(rest) = strip_prefix_ci(token, "project:").or_else(|| strip_prefix_ci(token, "proj:")) { // Project: project:Name or proj:Name if !rest.is_empty() { task.project_name = Some(rest.to_string()); } } else if let Some(rest) = strip_prefix_ci(token, "priority:").or_else(|| strip_prefix_ci(token, "pri:")) { // Priority: priority:H or pri:high match parse_priority(rest) { Some(p) => task.priority = Some(p), None => warnings.push(format!("Unrecognized priority: '{}'", rest)), } } else if let Some(rest) = strip_prefix_ci(token, "due:") { // Due date: due:tomorrow, due:2026-02-15, due:mon match parse_due_date(rest) { Some(d) => task.due = Some(d), None => warnings.push(format!("Unrecognized date format: '{}'", rest)), } } else if let Some(rest) = strip_prefix_ci(token, "recur:").or_else(|| strip_prefix_ci(token, "rec:")) { // Recurrence: recur:daily, recur:weekly, recur:monthly match parse_recurrence(rest) { Some(r) => task.recurrence = Some(r), None => warnings.push(format!("Unrecognized recurrence: '{}'", rest)), } } else { // Regular word - part of description description_parts.push(token); } } task.description = description_parts.join(" "); ParseResult { task, warnings } } use crate::text_utils::strip_prefix_ci; /// Parse priority from string fn parse_priority(s: &str) -> Option { match s.to_lowercase().as_str() { "h" | "high" => Some(Priority::High), "m" | "medium" | "med" => Some(Priority::Medium), "l" | "low" => Some(Priority::Low), _ => None, } } /// Parse due date from various formats fn parse_due_date(s: &str) -> Option> { let s_lower = s.to_lowercase(); let today = Utc::now().date_naive(); let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?; match s_lower.as_str() { "today" | "tod" => { let dt = today.and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } "tomorrow" | "tom" => { let dt = (today + Duration::days(1)).and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } "yesterday" | "yes" => { let dt = (today - Duration::days(1)).and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } // Day names - find next occurrence "monday" | "mon" => next_weekday(today, chrono::Weekday::Mon, default_time), "tuesday" | "tue" => next_weekday(today, chrono::Weekday::Tue, default_time), "wednesday" | "wed" => next_weekday(today, chrono::Weekday::Wed, default_time), "thursday" | "thu" => next_weekday(today, chrono::Weekday::Thu, default_time), "friday" | "fri" => next_weekday(today, chrono::Weekday::Fri, default_time), "saturday" | "sat" => next_weekday(today, chrono::Weekday::Sat, default_time), "sunday" | "sun" => next_weekday(today, chrono::Weekday::Sun, default_time), // Relative days: +1d, +2w, +1m _ if s_lower.starts_with('+') => parse_relative_date(&s_lower[1..], today, default_time), // ISO date format: 2026-02-15 _ => { if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { let dt = date.and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } else { None } } } } /// Find the next occurrence of a weekday fn next_weekday(from: NaiveDate, target: chrono::Weekday, time: NaiveTime) -> Option> { use chrono::Datelike; let current = from.weekday(); let current_num = current.num_days_from_monday(); let target_num = target.num_days_from_monday(); let days_ahead = if target_num > current_num { target_num - current_num } else if target_num < current_num { 7 - (current_num - target_num) } else { 7 // Same day, go to next week }; let dt = (from + Duration::days(days_ahead as i64)).and_time(time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } /// Parse relative date like "1d", "2w", "1m" fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option> { if s.is_empty() { return None; } let (num_str, unit) = s.split_at(s.len() - 1); let num: i64 = num_str.parse().ok()?; if num < 0 || num > MAX_RELATIVE_DATE_DAYS { return None; } let target = match unit { "d" => from + Duration::days(num), "w" => from + Duration::weeks(num), "m" => { // Approximate month from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH) } _ => return None, }; let dt = target.and_time(time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } /// Parse recurrence from string fn parse_recurrence(s: &str) -> Option { match s.to_lowercase().as_str() { "daily" | "d" | "day" => Some(Recurrence::Daily), "weekly" | "w" | "week" => Some(Recurrence::Weekly), "monthly" | "m" | "month" => Some(Recurrence::Monthly), "none" | "n" => Some(Recurrence::None), _ => None, } } #[cfg(test)] mod tests { use super::*; use chrono::Datelike; #[test] fn test_simple_description() { let result = parse_quick_add("Buy groceries"); assert_eq!(result.description, "Buy groceries"); assert!(result.tags.is_empty()); assert!(result.priority.is_none()); } #[test] fn test_with_tags() { let result = parse_quick_add("Buy groceries +shopping +errands"); assert_eq!(result.description, "Buy groceries"); assert_eq!(result.tags, vec!["shopping", "errands"]); } #[test] fn test_with_priority() { let result = parse_quick_add("Important task pri:H"); assert_eq!(result.description, "Important task"); assert_eq!(result.priority, Some(Priority::High)); let result2 = parse_quick_add("Medium task priority:medium"); assert_eq!(result2.priority, Some(Priority::Medium)); } #[test] fn test_with_project() { let result = parse_quick_add("Fix bug project:Claudetainment"); assert_eq!(result.description, "Fix bug"); assert_eq!(result.project_name, Some("Claudetainment".to_string())); } #[test] fn test_with_recurrence() { let result = parse_quick_add("Daily standup recur:daily"); assert_eq!(result.description, "Daily standup"); assert_eq!(result.recurrence, Some(Recurrence::Daily)); let result2 = parse_quick_add("Weekly review rec:weekly"); assert_eq!(result2.recurrence, Some(Recurrence::Weekly)); } #[test] fn test_with_due_tomorrow() { let result = parse_quick_add("Submit report due:tomorrow"); assert_eq!(result.description, "Submit report"); assert!(result.due.is_some()); // Due should be tomorrow let due = result.due.unwrap(); let tomorrow = Utc::now().date_naive() + Duration::days(1); assert_eq!(due.date_naive(), tomorrow); } #[test] fn test_complex() { let result = parse_quick_add("Call mom +family +important due:sunday pri:H proj:Personal"); assert_eq!(result.description, "Call mom"); assert_eq!(result.tags, vec!["family", "important"]); assert_eq!(result.priority, Some(Priority::High)); assert_eq!(result.project_name, Some("Personal".to_string())); assert!(result.due.is_some()); } #[test] fn test_iso_date() { let result = parse_quick_add("Meeting due:2026-03-15"); assert!(result.due.is_some()); let due = result.due.unwrap(); assert_eq!(due.date_naive().to_string(), "2026-03-15"); } #[test] fn test_relative_date() { let result = parse_quick_add("Task due:+3d"); assert!(result.due.is_some()); let due = result.due.unwrap(); let expected = Utc::now().date_naive() + Duration::days(3); assert_eq!(due.date_naive(), expected); } #[test] fn test_relative_weeks() { let result = parse_quick_add("Task due:+2w"); assert!(result.due.is_some()); let due = result.due.unwrap(); let expected = Utc::now().date_naive() + Duration::weeks(2); assert_eq!(due.date_naive(), expected); } #[test] fn test_relative_months() { let result = parse_quick_add("Task due:+1m"); assert!(result.due.is_some()); // Approximate month = 30 days let due = result.due.unwrap(); let expected = Utc::now().date_naive() + Duration::days(30); assert_eq!(due.date_naive(), expected); } #[test] fn test_priority_variations() { assert_eq!(parse_quick_add("Task pri:h").priority, Some(Priority::High)); assert_eq!(parse_quick_add("Task pri:H").priority, Some(Priority::High)); assert_eq!(parse_quick_add("Task pri:high").priority, Some(Priority::High)); assert_eq!(parse_quick_add("Task pri:HIGH").priority, Some(Priority::High)); assert_eq!(parse_quick_add("Task priority:m").priority, Some(Priority::Medium)); assert_eq!(parse_quick_add("Task priority:med").priority, Some(Priority::Medium)); assert_eq!(parse_quick_add("Task pri:l").priority, Some(Priority::Low)); assert_eq!(parse_quick_add("Task pri:low").priority, Some(Priority::Low)); } #[test] fn test_recurrence_variations() { assert_eq!(parse_quick_add("Task rec:d").recurrence, Some(Recurrence::Daily)); assert_eq!(parse_quick_add("Task rec:daily").recurrence, Some(Recurrence::Daily)); assert_eq!(parse_quick_add("Task recur:w").recurrence, Some(Recurrence::Weekly)); assert_eq!(parse_quick_add("Task recur:week").recurrence, Some(Recurrence::Weekly)); assert_eq!(parse_quick_add("Task rec:m").recurrence, Some(Recurrence::Monthly)); assert_eq!(parse_quick_add("Task rec:month").recurrence, Some(Recurrence::Monthly)); assert_eq!(parse_quick_add("Task rec:n").recurrence, Some(Recurrence::None)); } #[test] fn test_day_names() { let result = parse_quick_add("Task due:mon"); assert!(result.due.is_some()); // Should be next Monday let due = result.due.unwrap(); assert_eq!(due.weekday(), chrono::Weekday::Mon); let result = parse_quick_add("Task due:friday"); assert!(result.due.is_some()); let due = result.due.unwrap(); assert_eq!(due.weekday(), chrono::Weekday::Fri); } #[test] fn test_today_and_tomorrow() { let result = parse_quick_add("Task due:today"); assert!(result.due.is_some()); assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive()); let result = parse_quick_add("Task due:tod"); assert!(result.due.is_some()); assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive()); let result = parse_quick_add("Task due:tom"); assert!(result.due.is_some()); assert_eq!(result.due.unwrap().date_naive(), Utc::now().date_naive() + Duration::days(1)); } #[test] fn test_multiple_tags() { let result = parse_quick_add("+first Task description +second +third"); assert_eq!(result.description, "Task description"); assert_eq!(result.tags, vec!["first", "second", "third"]); } #[test] fn test_empty_input() { let result = parse_quick_add(""); assert_eq!(result.description, ""); assert!(result.tags.is_empty()); assert!(result.priority.is_none()); } #[test] fn test_only_modifiers() { let result = parse_quick_add("+tag pri:H due:tomorrow"); assert_eq!(result.description, ""); assert_eq!(result.tags, vec!["tag"]); assert_eq!(result.priority, Some(Priority::High)); assert!(result.due.is_some()); } #[test] fn test_project_with_spaces_not_supported() { // Projects with spaces need quoting or different syntax let result = parse_quick_add("Task proj:My Project"); assert_eq!(result.project_name, Some("My".to_string())); // Only gets "My" assert!(result.description.contains("Project")); // "Project" becomes description } #[test] fn test_case_insensitive_prefixes() { assert_eq!(parse_quick_add("Task PRI:H").priority, Some(Priority::High)); assert!(parse_quick_add("Task DUE:tomorrow").due.is_some()); assert_eq!(parse_quick_add("Task PROJECT:Test").project_name, Some("Test".to_string())); assert_eq!(parse_quick_add("Task RECUR:daily").recurrence, Some(Recurrence::Daily)); } #[test] fn test_warnings_for_invalid_values() { let result = parse_quick_add_with_warnings("Task pri:invalid"); assert!(!result.is_clean()); assert!(result.warnings.iter().any(|w| w.contains("priority"))); let result = parse_quick_add_with_warnings("Task due:notadate"); assert!(!result.is_clean()); assert!(result.warnings.iter().any(|w| w.contains("date"))); let result = parse_quick_add_with_warnings("Task rec:invalid"); assert!(!result.is_clean()); assert!(result.warnings.iter().any(|w| w.contains("recurrence"))); } #[test] fn test_no_warnings_for_valid_input() { let result = parse_quick_add_with_warnings("Fix bug +work pri:H due:tomorrow"); assert!(result.is_clean()); assert!(result.warnings.is_empty()); } #[test] fn test_empty_tag_ignored() { let result = parse_quick_add("Task + +valid"); // Empty tag after first + should be skipped assert_eq!(result.tags, vec!["valid"]); } #[test] fn test_strip_prefix_ci_helper() { assert_eq!(strip_prefix_ci("PROJECT:test", "project:"), Some("test")); assert_eq!(strip_prefix_ci("Project:test", "project:"), Some("test")); assert_eq!(strip_prefix_ci("proj:test", "project:"), None); assert_eq!(strip_prefix_ci("pr", "project:"), None); } }