use crate::core::model::{Category, ColorTag, State, SubTask, Todo};
use crate::core::store::Store;
use anyhow::{bail, Result};
use chrono::Local;
pub fn add_todo(store: &dyn Store, category: Category, title: String, notes: Option<String>) -> Result<u64> {
let mut state = store.load()?;
let id = state.next_id;
state.next_id += 1;
let now = Local::now().timestamp();
let todo = Todo {
id,
title,
notes,
created_at: now,
updated_at: now,
color: ColorTag::None,
subtasks: Vec::new(),
};
state.get_category_mut(category).push(todo);
store.save(&state)?;
Ok(id)
}
pub fn edit_todo(store: &dyn Store, id: u64, title: Option<String>, notes: Option<String>) -> Result<()> {
let mut state = store.load()?;
let now = Local::now().timestamp();
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
if let Some(todo) = vec.iter_mut().find(|t| t.id == id) {
if let Some(t) = title { todo.title = t; }
if let Some(n) = notes { todo.notes = Some(n); }
todo.updated_at = now;
store.save(&state)?;
return Ok(());
}
}
bail!("Todo not found: {}", id)
}
pub fn move_todo(store: &dyn Store, id: u64, target_cat: Category) -> Result<()> {
let mut state = store.load()?;
let now = Local::now().timestamp();
let mut found_todo: Option<Todo> = None;
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
if let Some(idx) = vec.iter().position(|t| t.id == id) {
found_todo = Some(vec.remove(idx));
break;
}
}
if let Some(mut todo) = found_todo {
todo.updated_at = now;
state.get_category_mut(target_cat).push(todo);
store.save(&state)?;
Ok(())
} else {
bail!("Todo not found: {}", id)
}
}
pub fn remove_todo(store: &dyn Store, id: u64) -> Result<()> {
let mut state = store.load()?;
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
if let Some(idx) = vec.iter().position(|t| t.id == id) {
vec.remove(idx);
store.save(&state)?;
return Ok(());
}
}
bail!("Todo not found: {}", id)
}
pub fn toggle_done(store: &dyn Store, id: u64) -> Result<()> {
let state = store.load()?;
let is_completed = state.completed.iter().any(|t| t.id == id);
if is_completed {
move_todo(store, id, Category::Short)
} else {
move_todo(store, id, Category::Completed)
}
}
pub fn reorder_item(store: &dyn Store, id: u64, move_up: bool) -> Result<()> {
let mut state = store.load()?;
let now = Local::now().timestamp();
let mut modified = false;
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
if let Some(idx) = vec.iter().position(|t| t.id == id) {
if move_up {
if idx > 0 {
vec.swap(idx, idx - 1);
vec[idx].updated_at = now;
vec[idx-1].updated_at = now;
modified = true;
}
} else if idx < vec.len() - 1 {
vec.swap(idx, idx + 1);
vec[idx].updated_at = now;
vec[idx+1].updated_at = now;
modified = true;
}
if modified { break; }
}
for todo in vec.iter_mut() {
if reorder_in_subs(&mut todo.subtasks, id, move_up, now) {
todo.updated_at = now;
modified = true;
break;
}
}
if modified { break; }
}
if modified {
store.save(&state)?;
Ok(())
} else {
Ok(())
}
}
fn reorder_in_subs(subs: &mut [SubTask], id: u64, move_up: bool, now: i64) -> bool {
if let Some(idx) = subs.iter().position(|s| s.id == id) {
if move_up {
if idx > 0 {
subs.swap(idx, idx - 1);
subs[idx].updated_at = now;
subs[idx-1].updated_at = now;
return true;
}
} else if idx < subs.len() - 1 {
subs.swap(idx, idx + 1);
subs[idx].updated_at = now;
subs[idx+1].updated_at = now;
return true;
}
return false; }
for sub in subs.iter_mut() {
if reorder_in_subs(&mut sub.subtasks, id, move_up, now) {
sub.updated_at = now;
return true;
}
}
false
}
pub fn add_subtask(store: &dyn Store, _parent_id: u64, title: String, notes: Option<String>) -> Result<u64> {
let mut state = store.load()?;
let id = state.next_id;
state.next_id += 1;
let now = Local::now().timestamp();
let sub = SubTask {
id,
title,
done: false,
created_at: now,
updated_at: now,
color: ColorTag::None,
notes,
subtasks: Vec::new(),
};
let mut added = false;
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
for todo in vec.iter_mut() {
if todo.id == _parent_id {
todo.subtasks.push(sub.clone());
todo.updated_at = now;
added = true;
break;
}
if add_to_sub(&mut todo.subtasks, _parent_id, &sub, now) {
todo.updated_at = now;
added = true;
break;
}
}
if added { break; }
}
if added {
store.save(&state)?;
Ok(id)
} else {
bail!("Parent not found: {}", _parent_id)
}
}
fn add_to_sub(subs: &mut [SubTask], parent_id: u64, new_sub: &SubTask, now: i64) -> bool {
for sub in subs.iter_mut() {
if sub.id == parent_id {
sub.subtasks.push(new_sub.clone());
sub.updated_at = now;
return true;
}
if add_to_sub(&mut sub.subtasks, parent_id, new_sub, now) {
sub.updated_at = now;
return true;
}
}
false
}
pub fn edit_subtask(store: &dyn Store, _parent_id: u64, sub_id: u64, title: Option<String>, done: Option<bool>, notes: Option<String>) -> Result<()> {
let mut state = store.load()?;
let now = Local::now().timestamp();
let mut found = false;
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
for todo in vec.iter_mut() {
if edit_in_subs(&mut todo.subtasks, sub_id, &title, done, ¬es, now) {
todo.updated_at = now;
found = true;
break;
}
}
if found { break; }
}
if found {
store.save(&state)?;
Ok(())
} else {
bail!("Subtask not found: {}", sub_id)
}
}
fn edit_in_subs(subs: &mut [SubTask], sub_id: u64, title: &Option<String>, done: Option<bool>, notes: &Option<String>, now: i64) -> bool {
for sub in subs.iter_mut() {
if sub.id == sub_id {
if let Some(t) = title { sub.title = t.clone(); }
if let Some(d) = done { sub.done = d; }
if let Some(n) = notes { sub.notes = Some(n.clone()); }
sub.updated_at = now;
return true;
}
if edit_in_subs(&mut sub.subtasks, sub_id, title, done, notes, now) {
sub.updated_at = now;
return true;
}
}
false
}
pub fn remove_subtask(store: &dyn Store, _parent_id: u64, sub_id: u64) -> Result<()> {
let mut state = store.load()?;
let now = Local::now().timestamp();
let mut found = false;
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
for todo in vec.iter_mut() {
if remove_from_subs(&mut todo.subtasks, sub_id, now) {
todo.updated_at = now;
found = true;
break;
}
}
if found { break; }
}
if found {
store.save(&state)?;
Ok(())
} else {
bail!("Subtask not found: {}", sub_id)
}
}
#[allow(clippy::ptr_arg)]
fn remove_from_subs(subs: &mut Vec<SubTask>, sub_id: u64, now: i64) -> bool {
if let Some(idx) = subs.iter().position(|s| s.id == sub_id) {
subs.remove(idx);
return true;
}
for sub in subs.iter_mut() {
if remove_from_subs(&mut sub.subtasks, sub_id, now) {
sub.updated_at = now;
return true;
}
}
false
}
pub fn set_color(store: &dyn Store, id: u64, color: ColorTag) -> Result<()> {
let mut state = store.load()?;
let now = Local::now().timestamp();
let mut found = false;
for cat in State::all_categories() {
let vec = state.get_category_mut(cat);
if let Some(todo) = vec.iter_mut().find(|t| t.id == id) {
todo.color = color;
todo.updated_at = now;
found = true;
} else {
for todo in vec.iter_mut() {
if set_color_in_subs(&mut todo.subtasks, id, color, now) {
todo.updated_at = now;
found = true;
break;
}
}
}
if found { break; }
}
if found {
store.save(&state)?;
Ok(())
} else {
bail!("Item not found: {}", id)
}
}
fn set_color_in_subs(subs: &mut [SubTask], id: u64, color: ColorTag, now: i64) -> bool {
for sub in subs.iter_mut() {
if sub.id == id {
sub.color = color;
sub.updated_at = now;
return true;
}
if set_color_in_subs(&mut sub.subtasks, id, color, now) {
sub.updated_at = now;
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::store::Store;
use std::sync::Mutex;
struct MockStore {
state: Mutex<State>,
}
impl MockStore {
fn new(state: State) -> Self {
Self { state: Mutex::new(state) }
}
}
impl Store for MockStore {
fn load(&self) -> Result<State> {
Ok(self.state.lock().unwrap().clone())
}
fn save(&self, state: &State) -> Result<()> {
*self.state.lock().unwrap() = state.clone();
Ok(())
}
}
#[test]
fn test_add_and_edit_todo() {
let store = MockStore::new(State::new());
let id = add_todo(&store, Category::Short, "Buy milk".to_string(), None).unwrap();
assert_eq!(id, 1);
let state = store.load().unwrap();
assert_eq!(state.short.len(), 1);
assert_eq!(state.short[0].title, "Buy milk");
edit_todo(&store, id, Some("Buy eggs".to_string()), Some("Organic".to_string())).unwrap();
let state = store.load().unwrap();
assert_eq!(state.short[0].title, "Buy eggs");
assert_eq!(state.short[0].notes, Some("Organic".to_string()));
}
#[test]
fn test_toggle_done() {
let store = MockStore::new(State::new());
let id = add_todo(&store, Category::Short, "Task".to_string(), None).unwrap();
toggle_done(&store, id).unwrap();
let state = store.load().unwrap();
assert_eq!(state.short.len(), 0);
assert_eq!(state.completed.len(), 1);
assert_eq!(state.completed[0].title, "Task");
toggle_done(&store, id).unwrap();
let state = store.load().unwrap();
assert_eq!(state.completed.len(), 0);
assert_eq!(state.short.len(), 1);
}
}