use std::collections::HashMap;
use std::path::PathBuf;
use aimcal_core::{
Aim, CalendarEntry, Config, DateTimeAnchor, Event, EventConditions, Id, LooseDateTime, Pager,
Priority, SortOrder, StoreDef, Todo, TodoConditions, TodoDraft, TodoSort, TodoStatus,
};
use jiff::{Zoned, civil::date, tz::TimeZone};
use crate::common::{TestConfigBuilder, setup_temp_dirs, test_event_draft, test_todo_draft};
#[tokio::test]
async fn config_path_expansion_relative_paths() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = TestConfigBuilder::new()
.with_calendar_path(&PathBuf::from("calendar"))
.with_state_dir(&temp_dirs.state_dir)
.build();
let result = Aim::new(config).await;
match result {
Ok(_) => {
}
Err(e) => {
let err_msg = e.to_string().to_lowercase();
assert!(
err_msg.contains("path")
|| err_msg.contains("directory")
|| err_msg.contains("file"),
"Error should be path-related: {e}"
);
}
}
}
#[tokio::test]
async fn config_default_due_applied() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: Some(DateTimeAnchor::InDays(7)),
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let draft = aim.default_todo_draft().unwrap();
assert!(
draft.due.is_some(),
"Default draft should have due date from config"
);
let todo_draft = TodoDraft {
calendar_id: None,
summary: "Task without due".to_string(),
description: None,
due: None,
percent_complete: None,
priority: None,
status: TodoStatus::NeedsAction,
};
let todo = aim.new_todo(todo_draft).await.unwrap();
assert!(
todo.due().is_none() || todo.due().is_some(),
"Todo due depends on draft"
);
}
#[tokio::test]
async fn config_default_priority_applied() {
let temp_dirs = setup_temp_dirs().await.unwrap();
for (default_priority, expected) in [
(Priority::P1, Priority::P1),
(Priority::P5, Priority::P5),
(Priority::P9, Priority::P9),
(Priority::None, Priority::None),
] {
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let draft = aim.default_todo_draft().unwrap();
assert_eq!(draft.priority, Some(expected));
}
}
#[tokio::test]
async fn config_priority_sorting_behavior() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config_none_first = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: true,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim_none_first = Aim::new(config_none_first).await.unwrap();
let priorities = [Priority::P5, Priority::None, Priority::P2, Priority::P9];
for (i, priority) in priorities.iter().enumerate() {
let draft = TodoDraft {
priority: Some(*priority),
..test_todo_draft(&format!("Task {i}"))
};
aim_none_first.new_todo(draft).await.unwrap();
}
let sort = vec![TodoSort::Priority {
order: SortOrder::Asc,
none_first: None, }];
let todos = aim_none_first
.list_todos(
&TodoConditions {
calendar_id: None,
status: None,
due: None,
},
&sort,
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(todos.len(), 4);
assert_eq!(todos[0].priority(), Priority::None);
let temp_dirs2 = setup_temp_dirs().await.unwrap();
let config_some_first = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs2.calendar_path.clone()),
state_dir: Some(temp_dirs2.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim_some_first = Aim::new(config_some_first).await.unwrap();
for (i, priority) in priorities.iter().enumerate() {
let draft = TodoDraft {
priority: Some(*priority),
..test_todo_draft(&format!("Task {i}"))
};
aim_some_first.new_todo(draft).await.unwrap();
}
let todos2 = aim_some_first
.list_todos(
&TodoConditions {
calendar_id: None,
status: None,
due: None,
},
&sort,
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(todos2.len(), 4);
assert_eq!(todos2[3].priority(), Priority::None);
}
#[tokio::test]
async fn config_timezone_handling() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let due_local = LooseDateTime::Local(Zoned::now());
let draft1 = TodoDraft {
due: Some(due_local),
..test_todo_draft("Local datetime")
};
let todo1 = aim.new_todo(draft1).await.unwrap();
assert!(todo1.due().is_some());
let uid1 = todo1.uid().as_ref().to_string();
let retrieved1 = aim.get_todo(&Id::Uid(uid1)).await.unwrap();
assert!(retrieved1.due().is_some());
}
fn multi_local_config(
state_dir: PathBuf,
calendars: Vec<CalendarEntry>,
default_calendar: &str,
) -> Config {
let mut stores = HashMap::new();
stores.insert(
"local".to_string(),
StoreDef::Local {
calendar_path: None,
},
);
Config {
stores,
calendar_path: None,
state_dir: Some(state_dir),
default_due: None,
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars,
default_calendar: default_calendar.to_string(),
}
}
#[tokio::test]
async fn multi_calendar_uses_configured_default_calendar() {
let root = tempfile::tempdir().unwrap();
let state_dir = root.path().join("state");
let personal_dir = root.path().join("personal");
let work_dir = root.path().join("work");
let config = multi_local_config(
state_dir,
vec![
CalendarEntry {
id: "work".to_string(),
name: "Work".to_string(),
store: "local".to_string(),
calendar_href: None,
calendar_path: Some(work_dir.to_string_lossy().to_string()),
priority: 0,
enabled: true,
},
CalendarEntry {
id: "personal".to_string(),
name: "Personal".to_string(),
store: "local".to_string(),
calendar_href: None,
calendar_path: Some(personal_dir.to_string_lossy().to_string()),
priority: 1,
enabled: true,
},
],
"personal",
);
let aim = Aim::new(config).await.unwrap();
aim.new_todo(test_todo_draft("Default calendar todo"))
.await
.unwrap();
let personal_todos = aim
.list_todos(
&TodoConditions {
status: None,
due: None,
calendar_id: Some("personal".to_string()),
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let all_todos = aim
.list_todos(
&TodoConditions {
status: None,
due: None,
calendar_id: None,
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let work_todos = aim
.list_todos(
&TodoConditions {
status: None,
due: None,
calendar_id: Some("work".to_string()),
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(
personal_todos.len(),
1,
"personal={}, work={}, total={}",
personal_todos.len(),
work_todos.len(),
all_todos.len()
);
assert!(work_todos.is_empty());
}
#[tokio::test]
async fn multi_calendar_missing_config_disables_calendar_without_deleting_data() {
let root = tempfile::tempdir().unwrap();
let state_dir = root.path().join("state");
let personal_dir = root.path().join("personal");
let work_dir = root.path().join("work");
let config = multi_local_config(
state_dir.clone(),
vec![
CalendarEntry {
id: "personal".to_string(),
name: "Personal".to_string(),
store: "local".to_string(),
calendar_href: None,
calendar_path: Some(personal_dir.to_string_lossy().to_string()),
priority: 0,
enabled: true,
},
CalendarEntry {
id: "work".to_string(),
name: "Work".to_string(),
store: "local".to_string(),
calendar_href: None,
calendar_path: Some(work_dir.to_string_lossy().to_string()),
priority: 1,
enabled: true,
},
],
"personal",
);
let aim = Aim::new(config).await.unwrap();
let mut personal = test_todo_draft("Visible");
personal.calendar_id = Some("personal".to_string());
aim.new_todo(personal).await.unwrap();
let mut work = test_todo_draft("Hidden");
work.calendar_id = Some("work".to_string());
aim.new_todo(work).await.unwrap();
aim.close().await.unwrap();
let config = multi_local_config(
state_dir,
vec![CalendarEntry {
id: "personal".to_string(),
name: "Personal".to_string(),
store: "local".to_string(),
calendar_href: None,
calendar_path: Some(personal_dir.to_string_lossy().to_string()),
priority: 0,
enabled: true,
}],
"personal",
);
let aim = Aim::new(config).await.unwrap();
let visible = aim
.list_todos(
&TodoConditions {
status: None,
due: None,
calendar_id: None,
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let hidden = aim
.list_todos(
&TodoConditions {
status: None,
due: None,
calendar_id: Some("work".to_string()),
},
&[],
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
let calendars = aim.list_calendars().await.unwrap();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].summary(), "Visible");
assert!(hidden.is_empty());
assert_eq!(aim.startup_notices().len(), 1);
assert!(
aim.startup_notices()[0].contains("work"),
"notice should mention disabled calendar: {:?}",
aim.startup_notices()
);
assert!(
calendars
.iter()
.any(|calendar| calendar.id == "work" && !calendar.enabled),
"work calendar should remain in DB but be disabled"
);
}
#[tokio::test]
async fn multi_calendar_event_listing_orders_by_calendar_priority() {
let root = tempfile::tempdir().unwrap();
let state_dir = root.path().join("state");
let personal_dir = root.path().join("personal");
let work_dir = root.path().join("work");
let config = multi_local_config(
state_dir,
vec![
CalendarEntry {
id: "personal".to_string(),
name: "Personal".to_string(),
store: "local".to_string(),
calendar_href: None,
calendar_path: Some(personal_dir.to_string_lossy().to_string()),
priority: 1,
enabled: true,
},
CalendarEntry {
id: "work".to_string(),
name: "Work".to_string(),
store: "local".to_string(),
calendar_href: None,
calendar_path: Some(work_dir.to_string_lossy().to_string()),
priority: 0,
enabled: true,
},
],
"personal",
);
let aim = Aim::new(config).await.unwrap();
let mut personal = test_event_draft("Personal");
personal.calendar_id = Some("personal".to_string());
personal.start = Some(LooseDateTime::Local(
date(2025, 1, 1)
.at(9, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.unwrap(),
));
personal.end = Some(LooseDateTime::Local(
date(2025, 1, 1)
.at(10, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.unwrap(),
));
aim.new_event(personal).await.unwrap();
let mut work = test_event_draft("Work");
work.calendar_id = Some("work".to_string());
work.start = Some(LooseDateTime::Local(
date(2025, 1, 1)
.at(12, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.unwrap(),
));
work.end = Some(LooseDateTime::Local(
date(2025, 1, 1)
.at(13, 0, 0, 0)
.to_zoned(TimeZone::UTC)
.unwrap(),
));
aim.new_event(work).await.unwrap();
let events = aim
.list_events(
&EventConditions {
startable: None,
cutoff: None,
calendar_id: None,
},
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].summary(), "Work");
assert_eq!(events[1].summary(), "Personal");
}
#[tokio::test]
async fn config_mixed_defaults_integration() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: Some(DateTimeAnchor::InDays(7)),
default_priority: Priority::P3,
default_priority_none_fist: true,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let draft = aim.default_todo_draft().unwrap();
assert_eq!(draft.summary, "");
assert_eq!(draft.priority, Some(Priority::P3));
assert!(draft.due.is_some());
assert_eq!(draft.status, TodoStatus::NeedsAction);
for i in 1..=5 {
let draft = TodoDraft {
calendar_id: None,
summary: format!("Task {i}"),
description: None,
due: None,
percent_complete: None,
priority: None,
status: TodoStatus::NeedsAction,
};
let todo = aim.new_todo(draft).await.unwrap();
assert_eq!(todo.summary().as_ref(), format!("Task {i}"));
}
let sort = vec![TodoSort::Priority {
order: SortOrder::Asc,
none_first: Some(true),
}];
let todos = aim
.list_todos(
&TodoConditions {
calendar_id: None,
status: None,
due: None,
},
&sort,
&Pager {
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(todos.len(), 5);
}
#[tokio::test]
async fn config_persistence_across_restarts() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: Some(DateTimeAnchor::InDays(7)),
default_priority: Priority::P5,
default_priority_none_fist: true,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim1 = Aim::new(config.clone()).await.unwrap();
let draft1 = aim1.default_todo_draft().unwrap();
assert_eq!(draft1.priority, Some(Priority::P5));
assert!(draft1.due.is_some());
let todo1 = aim1
.new_todo(test_todo_draft("Persistent Todo"))
.await
.unwrap();
let uid = todo1.uid().as_ref().to_string();
aim1.close().await.unwrap();
let aim2 = Aim::new(config).await.unwrap();
let draft2 = aim2.default_todo_draft().unwrap();
assert_eq!(draft2.priority, Some(Priority::P5));
assert!(draft2.due.is_some());
let todo2 = aim2.get_todo(&Id::Uid(uid)).await.unwrap();
assert_eq!(todo2.summary().as_ref(), "Persistent Todo");
}
#[tokio::test]
async fn config_default_draft_consistency() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: Some(DateTimeAnchor::InDays(1)),
default_priority: Priority::P2,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let draft1 = aim.default_todo_draft().unwrap();
let draft2 = aim.default_todo_draft().unwrap();
let draft3 = aim.default_todo_draft().unwrap();
assert_eq!(draft1.priority, draft2.priority);
assert_eq!(draft2.priority, draft3.priority);
assert_eq!(draft1.priority, Some(Priority::P2));
assert!(draft1.due.is_some());
assert!(draft2.due.is_some());
assert!(draft3.due.is_some());
assert_eq!(draft1.status, TodoStatus::NeedsAction);
assert_eq!(draft2.status, TodoStatus::NeedsAction);
assert_eq!(draft3.status, TodoStatus::NeedsAction);
}
#[tokio::test]
async fn config_event_defaults() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: Some(DateTimeAnchor::InDays(7)),
default_priority: Priority::P5,
default_priority_none_fist: true,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let draft = aim.default_event_draft();
assert!(
draft.start.is_some(),
"Default event draft should have start time"
);
assert!(
draft.end.is_some(),
"Default event draft should have end time"
);
assert_eq!(draft.summary, "");
}
#[tokio::test]
async fn config_datetime_anchor_variations() {
let temp_dirs = setup_temp_dirs().await.unwrap();
let anchors = vec![
DateTimeAnchor::today(),
DateTimeAnchor::tomorrow(),
DateTimeAnchor::InDays(1),
DateTimeAnchor::InDays(7),
DateTimeAnchor::InDays(30),
];
for anchor in anchors {
let config = Config {
stores: HashMap::new(),
calendar_path: Some(temp_dirs.calendar_path.clone()),
state_dir: Some(temp_dirs.state_dir.clone()),
default_due: Some(anchor.clone()),
default_priority: Priority::None,
default_priority_none_fist: false,
config_dir: None,
dev_mode: false,
calendars: Vec::new(),
default_calendar: "default".to_string(),
};
let aim = Aim::new(config).await.unwrap();
let draft = aim.default_todo_draft().unwrap();
assert!(
draft.due.is_some(),
"Due should be set for anchor {:?}",
anchor
);
}
}