use cfait::model::Task;
use chrono::{Datelike, Duration, Local};
use std::collections::HashMap;
fn parse(input: &str) -> Task {
Task::new(input, &HashMap::new(), None)
}
#[test]
fn test_recurrence_permutations() {
assert_eq!(parse("Task @daily").rrule, Some("FREQ=DAILY".to_string()));
assert_eq!(parse("Task @weekly").rrule, Some("FREQ=WEEKLY".to_string()));
assert_eq!(
parse("Task @monthly").rrule,
Some("FREQ=MONTHLY".to_string())
);
assert_eq!(parse("Task @yearly").rrule, Some("FREQ=YEARLY".to_string()));
assert_eq!(
parse("Task rec:daily").rrule,
Some("FREQ=DAILY".to_string())
);
assert_eq!(
parse("@every 3 days").rrule,
Some("FREQ=DAILY;INTERVAL=3".to_string())
);
assert_eq!(
parse("@every 2 weeks").rrule,
Some("FREQ=WEEKLY;INTERVAL=2".to_string())
);
assert_eq!(
parse("@every 5d").rrule,
Some("FREQ=DAILY;INTERVAL=5".to_string())
);
assert_eq!(
parse("@every 2w").rrule,
Some("FREQ=WEEKLY;INTERVAL=2".to_string())
);
assert_eq!(
parse("rec:every 1 month").rrule,
Some("FREQ=MONTHLY;INTERVAL=1".to_string())
);
assert_eq!(
parse("rec:3d").rrule,
Some("FREQ=DAILY;INTERVAL=3".to_string())
);
assert_eq!(
parse("rec:2mo").rrule,
Some("FREQ=MONTHLY;INTERVAL=2".to_string())
);
assert_eq!(
parse("@every two years").rrule,
Some("FREQ=YEARLY;INTERVAL=2".to_string())
);
}
#[test]
fn test_recurrence_weekday() {
let t = parse("Weekly meeting @every wednesday");
assert_eq!(t.rrule, Some("FREQ=WEEKLY;BYDAY=WE".to_string()));
let s = t.to_smart_string();
assert!(s.contains("@every wednesday"));
}
#[test]
fn test_date_iso_explicit() {
let t = parse("@2025-12-31");
let d = t.due.unwrap();
assert_eq!(d.to_date_naive().year(), 2025);
assert_eq!(d.to_date_naive().month(), 12);
assert_eq!(d.to_date_naive().day(), 31);
}
#[test]
fn test_date_keywords() {
assert!(parse("Task today").due.is_none());
assert!(parse("Task tomorrow").due.is_none());
assert!(parse("Task @today").due.is_some());
assert!(parse("Task @tomorrow").due.is_some());
assert!(parse("Task ^tomorrow").dtstart.is_some());
assert!(!parse("Task rem:tomorrow").alarms.is_empty());
assert!(parse("Task @tomorrow").alarms.is_empty());
assert!(parse("Task due:today").due.is_some());
let t_wed = parse("Meeting @wednesday");
assert!(t_wed.due.is_some());
assert_eq!(
t_wed.due.unwrap().to_date_naive().weekday(),
chrono::Weekday::Wed
);
}
#[test]
fn test_start_date_permutations() {
assert!(parse("^today").dtstart.is_some());
assert!(parse("^2025-01-01").dtstart.is_some());
assert!(parse("start:tomorrow").dtstart.is_some());
let t = parse("^3d");
let now = Local::now().date_naive();
let expected = now + Duration::days(3);
assert_eq!(t.dtstart.unwrap().to_date_naive(), expected);
}
#[test]
fn test_relative_offsets() {
let now = Local::now().date_naive();
let t1 = parse("@in 5 days");
assert_eq!(t1.due.unwrap().to_date_naive(), now + Duration::days(5));
let t2 = parse("@in 5d");
assert_eq!(t2.due.unwrap().to_date_naive(), now + Duration::days(5));
let t3 = parse("@1w");
assert_eq!(t3.due.unwrap().to_date_naive(), now + Duration::days(7));
}
#[test]
fn test_next_weekday() {
let t = parse("@next friday");
assert!(t.due.is_some());
let due = t.due.unwrap();
assert_eq!(due.to_date_naive().weekday(), chrono::Weekday::Fri);
assert!(due > cfait::model::DateType::Specific(chrono::Utc::now()));
}
#[test]
fn test_date_with_time_range() {
let t = parse("sometask ^@2026-06-06 15:00-18:30");
assert!(t.dtstart.is_some());
assert!(t.due.is_some());
let start = t.dtstart.as_ref().unwrap();
let due = t.due.as_ref().unwrap();
assert_eq!(start.format_smart(), "2026-06-06 15:00");
assert_eq!(due.format_smart(), "2026-06-06 18:30");
let s = t.to_smart_string();
assert!(
s.contains("^@2026-06-06 15:00-18:30"),
"Smart string was: {}",
s
);
}
#[test]
fn test_priority_formats() {
assert_eq!(parse("!1").priority, 1);
assert_eq!(parse("!9").priority, 9);
let _t_inv = parse("!0");
assert_eq!(parse("!99").priority, 9);
let t_text = parse("!important");
assert_eq!(t_text.priority, 0);
assert!(t_text.summary.contains("!important"));
}
#[test]
fn test_duration_formats() {
assert_eq!(parse("~30m").estimated_duration, Some(30));
assert_eq!(parse("est:30m").estimated_duration, Some(30));
assert_eq!(parse("~2h").estimated_duration, Some(120));
assert_eq!(parse("~1d").estimated_duration, Some(1440));
let t = parse("~huge");
assert!(t.estimated_duration.is_none());
assert!(t.summary.contains("~huge"));
}
#[test]
fn test_negative_plain_numbers() {
let t = parse("Project 5d");
assert!(t.dtstart.is_none());
assert!(t.due.is_none());
assert_eq!(t.summary, "Project 5d");
}
#[test]
fn test_negative_email_addresses() {
let t = parse("Email bob@example.com");
assert!(t.due.is_none());
assert!(t.summary.contains("bob@example.com"));
}
#[test]
fn test_negative_twitter_handles() {
let t = parse("Follow @rustlang");
assert!(t.due.is_none());
assert!(t.summary.contains("@rustlang"));
}
#[test]
fn test_negative_search_operators() {
let t1 = parse("Search !<2");
assert_eq!(t1.priority, 0);
assert!(t1.summary.contains("!<2"));
let t2 = parse("Filter @>2023-01-01");
assert!(t2.due.is_none());
assert!(t2.summary.contains("@>2023-01-01"));
}
#[test]
fn test_negative_orphaned_sigils() {
let t1 = parse("Meet @ 5");
assert!(t1.due.is_none());
assert!(t1.summary.contains("@"));
let t2 = parse("Is it ^ ?");
assert!(t2.dtstart.is_none());
assert!(t2.summary.contains("^"));
}
#[test]
fn test_negative_locations() {
let t = parse("Look at @@");
assert!(t.locations.is_empty());
assert_eq!(t.summary, "Look at @@");
let t2 = parse("Go to loc:");
assert!(t2.locations.is_empty());
assert_eq!(t2.summary, "Go to loc:");
}
#[test]
fn test_case_insensitivity() {
assert_eq!(parse("@DAILY").rrule, Some("FREQ=DAILY".to_string()));
assert_eq!(parse("@Weekly").rrule, Some("FREQ=WEEKLY".to_string()));
assert!(parse("@ToDaY").due.is_some());
assert_eq!(
parse("URL:example.com").url,
Some("https://example.com".to_string())
);
assert_eq!(parse("LOC:Home").locations, vec!["Home".to_string()]);
}
#[test]
fn test_start_date_weekdays() {
let t = parse("Start ^monday");
assert!(t.dtstart.is_some());
assert_eq!(
t.dtstart.unwrap().to_date_naive().weekday(),
chrono::Weekday::Mon
);
let t2 = parse("Start start:friday");
assert!(t2.dtstart.is_some());
assert_eq!(
t2.dtstart.unwrap().to_date_naive().weekday(),
chrono::Weekday::Fri
);
}
#[test]
fn test_quoted_values() {
let t1 = parse("Work on #\"Project X\"");
assert!(t1.categories.contains(&"Project X".to_string()));
let t2 = parse("Meeting @@\"Conference Room B\"");
assert_eq!(t2.locations, vec!["Conference Room B".to_string()]);
let t3 = parse("desc:\"Call mom back\"");
assert_eq!(t3.description, "Call mom back");
}
#[test]
fn test_url_complexity() {
let url = "https://example.com/path?query=1¶m=2";
let t = parse(&format!("Check url:{}", url));
assert_eq!(t.url, Some(url.to_string()));
let t2 = parse("[[https://example.com]]");
assert_eq!(t2.url, Some("https://example.com".to_string()));
}
#[test]
fn test_negative_implicit_next() {
let t = parse("See you next friday");
assert!(t.due.is_none());
assert!(t.dtstart.is_none());
assert_eq!(t.summary, "See you next friday");
}
#[test]
fn test_kitchen_sink() {
let input =
"Big Task !1 @tomorrow ^today ~2h #work #urgent @@Office url:github.com desc:\"Check PR\"";
let t = parse(input);
assert_eq!(t.summary, "Big Task");
assert_eq!(t.priority, 1);
assert!(t.due.is_some());
assert!(t.dtstart.is_some());
assert_eq!(t.estimated_duration, Some(120));
assert!(t.categories.contains(&"work".to_string()));
assert!(t.categories.contains(&"urgent".to_string()));
assert_eq!(t.locations, vec!["Office".to_string()]);
assert_eq!(t.url, Some("https://github.com".to_string()));
assert_eq!(t.description, "Check PR");
}
#[test]
fn test_recurrence_mixed_casing_and_spacing() {
let t = parse("Bill @every 2 Months");
assert_eq!(t.rrule, Some("FREQ=MONTHLY;INTERVAL=2".to_string()));
}
#[test]
fn test_tokenize_smart_input_search_mode_operators() {
use cfait::model::parser::{SyntaxType, tokenize_smart_input};
let input = "is:done -urgent | (work and not personal)";
let tokens = tokenize_smart_input(input, true);
let operators: Vec<_> = tokens
.iter()
.filter(|t| t.kind == SyntaxType::Operator)
.collect();
assert!(!operators.is_empty());
let filters: Vec<_> = tokens
.iter()
.filter(|t| t.kind == SyntaxType::Filter)
.collect();
assert!(!filters.is_empty());
}
#[test]
fn test_until_stress_variations() {
let t1 = parse("@daily until 2025-12-31");
assert!(t1.rrule.as_ref().unwrap().contains("UNTIL=20251231"));
let t2 = parse("@weekly UNTIL 2025-06-30");
assert!(t2.rrule.as_ref().unwrap().contains("UNTIL=20250630"));
let t3 = parse("@every 3 days Until 2026-01-01");
assert!(t3.rrule.as_ref().unwrap().contains("UNTIL=20260101"));
let t4 = parse("@daily until tomorrow");
assert!(t4.rrule.as_ref().unwrap().contains("UNTIL="));
let t5 = parse("@weekly until 30d");
assert!(t5.rrule.as_ref().unwrap().contains("UNTIL="));
}
#[test]
fn test_except_stress_variations() {
use cfait::model::DateType;
let t1 = parse("@daily except 2025-01-15");
assert_eq!(t1.exdates.len(), 1);
assert!(matches!(t1.exdates[0], DateType::AllDay(_)));
let t2 = parse("@weekly except 2025-01-15 except 2025-02-20 except 2025-03-10");
assert_eq!(t2.exdates.len(), 3);
let t3 = parse("@daily except tomorrow");
assert_eq!(t3.exdates.len(), 1);
let t4 = parse("@weekly except 5d");
assert_eq!(t4.exdates.len(), 1);
let t5 = parse("@daily EXCEPT 2025-01-01");
assert_eq!(t5.exdates.len(), 1);
let t6 = parse("@daily Except 2025-02-02");
assert_eq!(t6.exdates.len(), 1);
}
#[test]
fn test_until_and_except_combined_stress() {
let t1 = parse("@daily until 2025-12-31 except 2025-12-25 except 2025-01-01");
assert!(t1.rrule.as_ref().unwrap().contains("UNTIL=20251231"));
assert_eq!(t1.exdates.len(), 2);
let t2 =
parse("Meeting !1 @every monday until 2025-06-30 except 2025-03-10 #work @@office ~1h");
assert_eq!(t2.priority, 1);
assert!(t2.rrule.as_ref().unwrap().contains("FREQ=WEEKLY"));
assert!(t2.rrule.as_ref().unwrap().contains("BYDAY=MO"));
assert!(t2.rrule.as_ref().unwrap().contains("UNTIL=20250630"));
assert_eq!(t2.exdates.len(), 1);
assert!(t2.categories.contains(&"work".to_string()));
assert_eq!(t2.locations, vec!["office".to_string()]);
assert_eq!(t2.estimated_duration, Some(60));
}
#[test]
fn test_until_negative_cases() {
let t1 = parse("Wait until 2025-12-31");
assert!(t1.rrule.is_none());
let t2 = parse("@daily until not-a-date");
assert!(t2.summary.contains("until"));
assert!(t2.summary.contains("not-a-date"));
let t3 = parse("until");
assert_eq!(t3.summary, "until");
}
#[test]
fn test_except_negative_cases() {
let t1 = parse("Everything except that");
assert_eq!(t1.exdates.len(), 0);
assert!(t1.summary.contains("except"));
let t2 = parse("@daily except invalid");
assert!(t2.summary.contains("except"));
assert!(t2.summary.contains("invalid"));
let t3 = parse("except");
assert_eq!(t3.summary, "except");
}
#[test]
fn test_until_except_round_trip() {
use cfait::model::DateType;
use chrono::NaiveDate;
let t1 = parse("Daily @daily until 2025-12-31");
let smart1 = t1.to_smart_string();
assert!(smart1.contains("@daily"));
assert!(smart1.contains("until 2025-12-31"));
let mut t2 = parse("Task @weekly");
t2.exdates.push(DateType::AllDay(
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
));
let smart2 = t2.to_smart_string();
assert!(smart2.contains("@weekly"));
assert!(smart2.contains("except 2025-01-15"));
let t3 = parse("Task @daily until 2025-12-31 except 2025-01-20 except 2025-02-15");
let smart3 = t3.to_smart_string();
assert!(smart3.contains("@daily"));
assert!(smart3.contains("until 2025-12-31"));
assert!(smart3.contains("except 2025-01-20"));
assert!(smart3.contains("except 2025-02-15"));
}
#[test]
fn test_translation_integrity_and_collisions() {
use cfait::model::parser::LEXICON;
use std::collections::HashSet;
let lex_guard = LEXICON.read().unwrap();
let lex = &*lex_guard;
for alias in lex.exact.keys() {
assert!(
!alias.contains(' '),
"Exact token '{}' contains a space! Multi-word tokens are unsupported.",
alias
);
}
for (alias, _) in &lex.prefixes {
assert!(
!alias.contains(' '),
"Prefix token '{}' contains a space!",
alias
);
}
for (prefix, p_token) in &lex.prefixes {
if let Some(e_token) = lex.exact.get(prefix.as_str()) {
panic!(
"Cross-type collision! '{}' is both a prefix ({:?}) and an exact token ({:?}). Prefix will shadow the exact token.",
prefix, p_token, e_token
);
}
}
let mut seen_prefixes = HashSet::new();
for (prefix, _) in &lex.prefixes {
if !seen_prefixes.insert(prefix.clone()) {
panic!("Duplicate prefix token: '{}'", prefix);
}
}
}