pub mod autocomplete;
pub mod category_list;
mod hooks;
pub mod parser;
pub mod search;
pub mod task_list;
pub mod todo_state;
pub mod version;
pub use self::{
autocomplete::autocomplete, category_list::CategoryList, parser::Parser, task_list::TaskList,
todo_state::*,
};
use crate::config::{HookPaths, SetFinalDateType, Styles, ToDoConfig};
use anyhow::Result;
use chrono::{NaiveDate, Utc};
use hooks::{HookTypes, Hooks};
use std::str::FromStr;
use todo_txt::task::Simple as Task;
use version::Version;
#[derive(Default)]
pub struct ToDo {
pub pending: Vec<Task>,
pub done: Vec<Task>,
version: Version,
state: ToDoState,
config: ToDoConfig,
styles: Styles,
hooks: Hooks,
}
impl ToDo {
pub fn new(config: ToDoConfig, hook_paths: HookPaths, styles: Styles) -> Self {
Self {
pending: Vec::new(),
done: Vec::new(),
version: Version::default(),
state: ToDoState::default(),
config,
styles,
hooks: Hooks::new(hook_paths),
}
}
pub fn move_data(&mut self, other: Self) {
self.pending = other.pending;
self.done = other.done;
self.version.update_all();
}
pub fn get_version(&self) -> &Version {
&self.version
}
pub fn get_version_mut(&mut self) -> &mut Version {
&mut self.version
}
fn get_actual_index(&self, data: ToDoData, index: usize) -> Option<usize> {
self.get_filtered_and_sorted(data).get_actual_index(index)
}
fn get_actual_date() -> NaiveDate {
Utc::now().naive_utc().date()
}
pub fn add_task(&mut self, task: Task) {
if task.finished {
self.done.push(task);
self.version.update(&ToDoData::Done);
} else {
self.pending.push(task);
self.version.update(&ToDoData::Pending);
}
}
pub fn get_categories(&self, category: ToDoCategory) -> CategoryList<'_> {
CategoryList::new(self, category)
}
pub fn move_task(&mut self, data: ToDoData, index: usize) {
self.version.update_all();
let index = match self.get_actual_index(data, index) {
Some(index) => index,
None => {
log::warn!("Cannot move task Layout::get_actual_index is None");
return;
}
};
let move_task_logic = |from: &mut Vec<Task>, to: &mut Vec<_>| {
if from.len() <= index {
return;
}
let mut task = from.remove(index);
if task.finished && self.config.delete_final_date {
task.finish_date = None;
}
if !task.finished {
match self.config.set_final_date {
SetFinalDateType::Override => task.finish_date = Some(Self::get_actual_date()),
SetFinalDateType::OnlyMissing if task.finish_date.is_none() => {
task.finish_date = Some(Self::get_actual_date())
}
_ => {}
}
}
task.finished = !task.finished;
to.push(task)
};
self.hooks.run_lazy(HookTypes::PreMove, || {
data.get_data(self)[index].to_string()
});
match data {
ToDoData::Pending => move_task_logic(&mut self.pending, &mut self.done),
ToDoData::Done => move_task_logic(&mut self.done, &mut self.pending),
};
self.hooks.run_lazy(HookTypes::PostMove, || {
data.get_data(self)[index].to_string()
});
self.fix_active(index)
}
pub fn toggle_filter(
&mut self,
category: ToDoCategory,
filter: &str,
filter_state: FilterState,
) {
self.state.set_filter(category, filter, filter_state)
}
fn get_filtered_tasks(&self, data: ToDoData) -> Vec<(usize, &Task)> {
data.get_data(self)
.iter()
.enumerate()
.filter(|(_, task)| self.state.filter_out(task))
.collect()
}
pub fn get_filtered_and_sorted(&self, data: ToDoData) -> TaskList<'_, '_> {
let mut task_list = TaskList::new(self.get_filtered_tasks(data), &self.styles);
task_list.sort(data.get_sorting(&self.config));
task_list
}
pub fn new_task(&mut self, task: &str) -> Result<()> {
let task_str = task.replace("due:today ", &format!("due:{}", Self::get_actual_date()));
let mut task_str = task_str.replace("due: ", &format!("due:{}", Self::get_actual_date()));
if let Some(new_task) = self.hooks.run(HookTypes::PreNew, &task_str) {
task_str = new_task;
}
let mut task = Task::from_str(&task_str)?;
if task.create_date.is_none() && self.config.set_created_date {
task.create_date = Some(Self::get_actual_date());
}
if task.finished {
self.done.push(task);
self.version.update(&ToDoData::Done);
} else {
self.pending.push(task);
self.version.update(&ToDoData::Pending);
}
self.hooks.run(HookTypes::PostNew, &task_str);
Ok(())
}
pub fn remove_task(&mut self, data: ToDoData, index: usize) {
let index = self.get_actual_index(data, index);
if let Some(index) = index {
self.hooks.run_lazy(HookTypes::PreRemove, || {
data.get_data(self)[index].to_string()
});
data.get_data_mut(self).remove(index);
self.hooks.run_lazy(HookTypes::PostRemove, || {
data.get_data(self)[index].to_string()
});
self.fix_active(index);
} else {
log::warn!("Layout::get_actual_index is None");
}
}
pub fn swap_tasks(&mut self, data: ToDoData, from: usize, to: usize) {
let from = self.get_actual_index(data, from);
let to = self.get_actual_index(data, to);
match (from, to) {
(Some(from), Some(to)) => {
data.get_data_mut(self).swap(from, to);
if let Some((_, act_index)) = &mut self.state.active {
if *act_index == from {
*act_index = to;
} else if *act_index == to {
*act_index = from;
}
}
}
_ => {
log::warn!("Canot swap from or to is None")
}
}
}
pub fn set_active(&mut self, data: ToDoData, index: usize) {
if let Some(index) = self.get_actual_index(data, index) {
self.state.active = Some((data, index));
} else {
log::warn!("Layout::get_actual_index is None");
}
}
pub fn get_active(&self) -> Option<&Task> {
let (data, index) = self.state.active?;
let list = data.get_data(self);
if index >= list.len() {
list.last()
} else {
list.get(index)
}
}
pub fn set_actual(&mut self, data: ToDoData, index: usize) {
match self.get_actual_index(data, index) {
Some(i) => match data {
ToDoData::Pending => self.state.actual_pending = Some(i),
ToDoData::Done => self.state.actual_done = Some(i),
},
None => log::warn!("Layout::get_actual_index is None"),
}
}
pub fn get_actual(&self, data: ToDoData) -> Option<&Task> {
let list = data.get_data(self);
match data {
ToDoData::Pending => match self.state.actual_pending {
Some(i) => list.get(i),
None => list.first(),
},
ToDoData::Done => match self.state.actual_done {
Some(i) => list.get(i),
None => list.first(),
},
}
}
pub fn update_active(&mut self, task: &str) -> Result<()> {
if let Some((data, index)) = self.state.active {
let mut task = task.to_string();
if let Some(new_task) = self.hooks.run(HookTypes::PreUpdate, &task) {
task = new_task;
}
data.get_data_mut(self)[index] = Task::from_str(&task)?;
self.hooks.run(HookTypes::PostUpdate, &task);
}
Ok(())
}
fn fix_active(&mut self, index: usize) {
if let Some((_, act_index)) = &mut self.state.active {
log::trace!("act: {}, moved: {}", act_index, index);
match index.cmp(act_index) {
std::cmp::Ordering::Less => *act_index -= 1,
std::cmp::Ordering::Equal => self.state.active = None,
std::cmp::Ordering::Greater => {}
}
}
}
pub fn len(&self, data: ToDoData) -> usize {
self.get_filtered_and_sorted(data).len()
}
pub fn get_state(&self) -> &ToDoState {
&self.state
}
pub fn update_state(&mut self, state: ToDoState) {
self.state = state
}
pub fn find_task(&self, data: ToDoData, to_find: &str) -> Vec<usize> {
let tasks = match data {
ToDoData::Pending => &self.pending,
ToDoData::Done => &self.done,
};
let (case_sensitive, to_find) = match to_find.chars().next() {
Some(c) if c.is_uppercase() => (true, to_find.to_uppercase()),
_ => (false, String::from(to_find)),
};
tasks
.iter()
.enumerate()
.filter_map(|(i, task)| match case_sensitive {
true if task.subject.to_uppercase().contains(&to_find) => Some(i),
false if task.subject.contains(&to_find) => Some(i),
_ => None,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::naive::NaiveDate;
use todo_txt::Priority;
fn example_todo() -> ToDo {
let mut todo = ToDo::default();
let mut task = Task::from_str("measure space for 1 +project1 @context1 #hashtag1").unwrap();
task.finished = true;
task.priority = Priority::from(0);
task.create_date = Some(NaiveDate::from_ymd_opt(2023, 4, 30).unwrap());
task.finish_date = Some(NaiveDate::from_ymd_opt(2023, 5, 21).unwrap());
task.due_date = Some(NaiveDate::from_ymd_opt(2023, 6, 30).unwrap());
todo.add_task(task);
let mut task = Task::from_str("measure space for 2 +project2 @context2").unwrap();
task.create_date = Some(NaiveDate::from_ymd_opt(2023, 4, 30).unwrap());
task.due_date = Some(NaiveDate::from_ymd_opt(2023, 6, 30).unwrap());
todo.add_task(task);
let mut task = Task::from_str("measure space for 3 +project3 @context3").unwrap();
task.priority = Priority::from(2);
task.create_date = Some(NaiveDate::from_ymd_opt(2023, 4, 30).unwrap());
task.due_date = Some(NaiveDate::from_ymd_opt(2023, 6, 30).unwrap());
todo.add_task(task);
let mut task = Task::from_str("measure space for +project2 @context3 #hashtag1").unwrap();
task.due_date = Some(NaiveDate::from_ymd_opt(2023, 6, 30).unwrap());
todo.add_task(task);
let mut task = Task::from_str("measure space for 5 +project3 @context3 #hashtag2").unwrap();
task.finished = true;
task.due_date = Some(NaiveDate::from_ymd_opt(2023, 6, 30).unwrap());
todo.add_task(task);
let mut task = Task::from_str("measure space for 6 +project3 @context2 #hashtag2").unwrap();
task.due_date = Some(NaiveDate::from_ymd_opt(2023, 6, 30).unwrap());
todo.add_task(task);
todo
}
#[test]
fn test_add_task() {
let mut todo = example_todo();
todo.config.use_done = true;
assert_eq!(todo.done.len(), 2);
assert_eq!(todo.pending.len(), 4);
assert_eq!(todo.done[0].priority, 0);
assert!(todo.done[0].create_date.is_some());
assert!(todo.done[0].finish_date.is_some());
assert!(todo.done[0].finished);
assert_eq!(todo.done[0].threshold_date, None);
assert!(todo.done[0].due_date.is_some());
assert_eq!(todo.done[0].contexts.len(), 1);
assert_eq!(todo.done[0].projects.len(), 1);
assert_eq!(todo.done[0].hashtags.len(), 1);
println!("{:#?}", todo.pending[0]);
assert!(todo.pending[0].priority.is_lowest());
assert!(todo.pending[0].create_date.is_some());
assert!(todo.pending[0].finish_date.is_none());
assert!(!todo.pending[0].finished);
assert_eq!(todo.pending[0].threshold_date, None);
assert!(todo.pending[0].due_date.is_some());
assert_eq!(todo.pending[0].contexts.len(), 1);
assert_eq!(todo.pending[0].projects.len(), 1);
assert_eq!(todo.pending[0].hashtags.len(), 0);
assert_eq!(todo.pending[1].priority, 2);
assert!(todo.pending[1].create_date.is_some());
assert!(todo.pending[1].finish_date.is_none());
assert!(!todo.pending[1].finished);
assert_eq!(todo.pending[1].threshold_date, None);
assert!(todo.pending[1].due_date.is_some());
assert_eq!(todo.pending[1].contexts.len(), 1);
assert_eq!(todo.pending[1].projects.len(), 1);
assert_eq!(todo.pending[1].hashtags.len(), 0);
}
#[test]
fn test_filtering() -> Result<()> {
let mut todo = ToDo::default();
todo.add_task(Task::from_str("task 1")?);
todo.add_task(Task::from_str("task 2 +project1")?);
todo.add_task(Task::from_str("task 3 +project1 +project2")?);
todo.add_task(Task::from_str("task 4 +project1 +project3")?);
todo.add_task(Task::from_str("task 5 +project1 +project2 +project3")?);
todo.add_task(Task::from_str(
"task 6 +project3 @context2 #hashtag2 #hashtag1",
)?);
todo.add_task(Task::from_str(
"task 7 +project2 @context1 #hashtag1 #hashtag2",
)?);
todo.add_task(Task::from_str("task 8 +project2 @context2")?);
todo.add_task(Task::from_str("task 9 +projects3 @context3")?);
todo.add_task(Task::from_str(
"task 10 +project2 @context3 #hashtag1 #hashtag2",
)?);
todo.add_task(Task::from_str(
"task 11 +project3 @context3 #hashtag2 #hashtag3",
)?);
todo.add_task(Task::from_str("task 12 +project3 @context2 #hashtag2")?);
let filtered = todo.get_filtered_and_sorted(ToDoData::Pending);
assert_eq!(filtered.len(), 12);
todo.state
.project_filters
.insert(String::from("project9999"), FilterState::Select);
let filtered = todo.get_filtered_and_sorted(ToDoData::Pending);
assert_eq!(filtered.len(), 0);
todo.state.project_filters.clear();
todo.state
.project_filters
.insert(String::from("project1"), FilterState::Select);
let filtered = todo.get_filtered_and_sorted(ToDoData::Pending);
assert_eq!(filtered.len(), 4);
assert_eq!(filtered[0].subject, "task 2 +project1");
assert_eq!(filtered[1].subject, "task 3 +project1 +project2");
assert_eq!(filtered[2].subject, "task 4 +project1 +project3");
assert_eq!(filtered[3].subject, "task 5 +project1 +project2 +project3");
todo.state
.project_filters
.insert(String::from("project2"), FilterState::Select);
let filtered = todo.get_filtered_and_sorted(ToDoData::Pending);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].subject, "task 3 +project1 +project2");
assert_eq!(filtered[1].subject, "task 5 +project1 +project2 +project3");
todo.state
.project_filters
.insert(String::from("project3"), FilterState::Select);
let filtered = todo.get_filtered_and_sorted(ToDoData::Pending);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].subject, "task 5 +project1 +project2 +project3");
todo.state
.project_filters
.insert(String::from("project1"), FilterState::Select);
let filtered = todo.get_filtered_and_sorted(ToDoData::Pending);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].subject, "task 5 +project1 +project2 +project3");
todo.state.project_filters.clear();
todo.state
.context_filters
.insert(String::from("context1"), FilterState::Select);
let filtered = todo.get_filtered_and_sorted(ToDoData::Pending);
assert_eq!(filtered.len(), 1);
assert_eq!(
filtered[0].subject,
"task 7 +project2 @context1 #hashtag1 #hashtag2"
);
Ok(())
}
#[test]
fn actual_consistency_move() {
let mut todo = example_todo();
todo.set_active(ToDoData::Pending, 2);
let subject = todo.get_active().unwrap().subject.clone();
todo.move_task(ToDoData::Pending, 3);
assert_eq!(todo.get_active().unwrap().subject, subject);
todo.move_task(ToDoData::Pending, 0);
assert_eq!(todo.get_active().unwrap().subject, subject);
todo.move_task(ToDoData::Pending, 1);
assert!(todo.get_active().is_none());
}
#[test]
fn actual_consistency_remove() {
let mut todo = example_todo();
todo.set_active(ToDoData::Pending, 2);
let subject = todo.get_active().unwrap().subject.clone();
todo.remove_task(ToDoData::Pending, 3);
assert_eq!(todo.get_active().unwrap().subject, subject);
todo.remove_task(ToDoData::Pending, 0);
assert_eq!(todo.get_active().unwrap().subject, subject);
todo.remove_task(ToDoData::Pending, 1);
assert!(todo.get_active().is_none());
}
#[test]
fn actual_consistency_swap() {
let mut todo = example_todo();
todo.set_active(ToDoData::Pending, 2);
let subject = todo.get_active().unwrap().subject.clone();
todo.swap_tasks(ToDoData::Pending, 0, 1);
assert_eq!(todo.get_active().unwrap().subject, subject);
todo.swap_tasks(ToDoData::Pending, 2, 0);
assert_eq!(todo.get_active().unwrap().subject, subject);
todo.swap_tasks(ToDoData::Pending, 1, 2);
assert_eq!(todo.get_active().unwrap().subject, subject);
}
#[test]
fn move_data() {
let todo = example_todo();
let mut empty = ToDo::default();
assert!(empty.pending.is_empty());
assert!(empty.done.is_empty());
empty.move_data(example_todo());
assert_eq!(todo.pending, empty.pending);
assert_eq!(todo.done, empty.done);
}
#[test]
fn version() {
let mut todo = ToDo::default();
assert!(todo.get_version().is_actual(0, &ToDoData::Pending));
assert!(todo.get_version().is_actual(0, &ToDoData::Done));
todo.move_data(example_todo());
println!("{:?}", todo.get_version());
assert!(todo.get_version().is_actual(1, &ToDoData::Pending));
assert!(todo.get_version().is_actual(1, &ToDoData::Done));
todo.move_task(ToDoData::Done, 1);
assert!(todo.get_version().is_actual(2, &ToDoData::Pending));
assert!(todo.get_version().is_actual(2, &ToDoData::Done));
todo.add_task(Task::from_str("Some simple task").unwrap());
assert!(todo.get_version().is_actual(3, &ToDoData::Pending));
assert!(todo.get_version().is_actual(2, &ToDoData::Done));
todo.add_task(Task::from_str("x Some simple task").unwrap());
assert!(todo.get_version().is_actual(3, &ToDoData::Pending));
assert!(todo.get_version().is_actual(3, &ToDoData::Done));
}
#[test]
fn toggle_filter() {
let mut todo = example_todo();
assert!(todo.state.project_filters.is_empty());
todo.toggle_filter(ToDoCategory::Projects, "project1", FilterState::Select);
assert_eq!(
todo.state.project_filters.get("project1"),
Some(&FilterState::Select)
);
assert_eq!(todo.state.project_filters.len(), 1);
todo.toggle_filter(ToDoCategory::Projects, "project1", FilterState::Select);
assert!(todo.state.project_filters.is_empty());
todo.toggle_filter(ToDoCategory::Contexts, "context1", FilterState::Select);
assert_eq!(
todo.state.context_filters.get("context1"),
Some(&FilterState::Select)
);
assert_eq!(todo.state.context_filters.len(), 1);
todo.toggle_filter(ToDoCategory::Contexts, "context1", FilterState::Select);
assert!(todo.state.context_filters.is_empty());
todo.toggle_filter(ToDoCategory::Hashtags, "hashtag1", FilterState::Select);
assert_eq!(
todo.state.hashtag_filters.get("hashtag1"),
Some(&FilterState::Select)
);
assert_eq!(todo.state.hashtag_filters.len(), 1);
todo.toggle_filter(ToDoCategory::Hashtags, "hashtag1", FilterState::Select);
assert!(todo.state.hashtag_filters.is_empty());
}
#[test]
fn new_task() -> Result<()> {
let mut todo = ToDo::default();
todo.new_task("Some pending task")?;
assert_eq!(todo.pending.len(), 1);
assert_eq!(todo.pending[0].subject, "Some pending task");
todo.new_task("x Some done task")?;
assert_eq!(todo.done.len(), 1);
assert_eq!(todo.done[0].subject, "Some done task");
Ok(())
}
#[test]
fn update_active() -> Result<()> {
let mut todo = example_todo();
todo.state.active = Some((ToDoData::Pending, 0));
todo.update_active("New subject")?;
assert_eq!(todo.pending[0].subject, "New subject");
todo.state.active = Some((ToDoData::Done, 0));
todo.update_active("New done subject")?;
assert_eq!(todo.done[0].subject, "New done subject");
Ok(())
}
#[test]
fn update_finish_date() {
let mut todo = example_todo();
todo.config.set_final_date = SetFinalDateType::OnlyMissing;
assert_eq!(todo.pending[0].finish_date, None);
todo.move_task(ToDoData::Pending, 0);
assert_eq!(
todo.done.last().unwrap().finish_date,
Some(ToDo::get_actual_date())
);
todo.config.set_final_date = SetFinalDateType::Never;
assert_eq!(todo.pending[0].finish_date, None);
todo.move_task(ToDoData::Pending, 0);
assert_eq!(todo.done.last().unwrap().finish_date, None);
todo.config.set_final_date = SetFinalDateType::Override;
todo.pending[0].finish_date = Some(NaiveDate::from_ymd_opt(2023, 4, 30).unwrap());
todo.move_task(ToDoData::Pending, 0);
assert_eq!(
todo.done.last().unwrap().finish_date,
Some(ToDo::get_actual_date())
);
todo.config.delete_final_date = true;
todo.done[0].finish_date = Some(NaiveDate::from_ymd_opt(2023, 4, 30).unwrap());
todo.move_task(ToDoData::Done, 0);
assert_eq!(todo.pending.last().unwrap().finish_date, None);
todo.config.delete_final_date = false;
let date = Some(NaiveDate::from_ymd_opt(2023, 4, 30).unwrap());
todo.done[0].finish_date = date;
todo.move_task(ToDoData::Done, 0);
assert_eq!(todo.pending.last().unwrap().finish_date, date);
}
#[test]
fn set_created_date() -> Result<()> {
let mut todo = example_todo();
todo.config.set_created_date = false;
todo.new_task("Test")?;
assert_eq!(todo.pending.last().unwrap().create_date, None);
todo.new_task("2025-09-02 Test")?;
assert_eq!(
todo.pending.last().unwrap().create_date,
NaiveDate::from_ymd_opt(2025, 9, 2)
);
todo.config.set_created_date = true;
todo.new_task("Test")?;
assert_eq!(
todo.pending.last().unwrap().create_date,
Some(Utc::now().naive_utc().date())
);
Ok(())
}
#[test]
fn get_actual_returns_first_when_not_set() {
let mut todo = ToDo::default();
todo.add_task(Task::from_str("first pending").unwrap());
todo.add_task(Task::from_str("second pending").unwrap());
let task = todo.get_actual(ToDoData::Pending).unwrap();
assert_eq!(task.subject, "first pending");
}
#[test]
fn get_actual_returns_none_when_empty() {
let todo = ToDo::default();
assert!(todo.get_actual(ToDoData::Pending).is_none());
assert!(todo.get_actual(ToDoData::Done).is_none());
}
#[test]
fn set_actual_pending() {
let mut todo = ToDo::default();
todo.add_task(Task::from_str("task 0").unwrap());
todo.add_task(Task::from_str("task 1").unwrap());
todo.add_task(Task::from_str("task 2").unwrap());
todo.set_actual(ToDoData::Pending, 2);
let task = todo.get_actual(ToDoData::Pending).unwrap();
assert_eq!(task.subject, "task 2");
}
#[test]
fn set_actual_done() {
let mut todo = ToDo::default();
todo.config.use_done = true;
todo.add_task(Task::from_str("x done 0").unwrap());
todo.add_task(Task::from_str("x done 1").unwrap());
todo.set_actual(ToDoData::Done, 1);
let task = todo.get_actual(ToDoData::Done).unwrap();
assert_eq!(task.subject, "done 1");
}
}