use chrono::{DateTime, Local, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::filters::{DueFilter, StatusFilter};
use super::priority::Priority;
use super::recurrence::Recurrence;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
#[serde(default)]
pub uuid: Uuid,
pub text: String,
pub completed: bool,
pub priority: Priority,
pub tags: Vec<String>,
#[serde(default)]
pub project_id: Option<Uuid>,
#[serde(default, rename = "project", skip_serializing)]
pub project_name_legacy: Option<String>,
pub due_date: Option<NaiveDate>,
pub created_at: DateTime<Utc>,
pub recurrence: Option<Recurrence>,
#[serde(default)]
pub parent_id: Option<Uuid>,
#[serde(default)]
pub depends_on: Vec<Uuid>,
#[serde(default)]
pub completed_at: Option<NaiveDate>,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub deleted_at: Option<DateTime<Utc>>,
}
impl Task {
pub fn new(
text: String,
priority: Priority,
tags: Vec<String>,
project_id: Option<Uuid>,
due_date: Option<NaiveDate>,
recurrence: Option<Recurrence>,
) -> Self {
Task {
uuid: Uuid::new_v4(),
text,
completed: false,
priority,
tags,
project_id,
project_name_legacy: None,
due_date,
created_at: Utc::now(),
recurrence,
parent_id: None,
depends_on: Vec::new(),
completed_at: None,
updated_at: Some(Utc::now()),
deleted_at: None,
}
}
pub fn touch(&mut self) {
self.updated_at = Some(Utc::now());
}
pub fn soft_delete(&mut self) {
self.deleted_at = Some(Utc::now());
self.touch();
}
pub fn is_deleted(&self) -> bool {
self.deleted_at.is_some()
}
pub fn mark_done(&mut self) {
self.completed = true;
self.completed_at = Some(Local::now().naive_local().date());
self.touch();
}
pub fn mark_undone(&mut self) {
self.completed = false;
self.completed_at = None;
self.touch();
}
pub fn is_overdue(&self) -> bool {
if let Some(due) = self.due_date {
let today = Local::now().naive_local().date();
due < today && !self.completed
} else {
false
}
}
pub fn is_due_soon(&self, days: i64) -> bool {
if let Some(due) = self.due_date {
let today = Local::now().naive_local().date();
let days_until = (due - today).num_days();
days_until >= 0 && days_until <= days && !self.completed
} else {
false
}
}
pub fn matches_status(&self, status: StatusFilter) -> bool {
match status {
StatusFilter::Pending => !self.completed,
StatusFilter::Done => self.completed,
StatusFilter::All => true,
}
}
pub fn matches_due_filter(&self, filter: DueFilter) -> bool {
match filter {
DueFilter::Overdue => self.is_overdue(),
DueFilter::Soon => self.is_due_soon(7),
DueFilter::WithDue => self.due_date.is_some(),
DueFilter::NoDue => self.due_date.is_none(),
}
}
pub fn is_blocked(&self, all_tasks: &[Task]) -> bool {
self.depends_on.iter().any(|dep_uuid| {
all_tasks
.iter()
.find(|t| t.uuid == *dep_uuid)
.map(|t| !t.completed)
.unwrap_or(false)
})
}
pub fn blocking_deps(&self, all_tasks: &[Task]) -> Vec<Uuid> {
self.depends_on
.iter()
.copied()
.filter(|dep_uuid| {
all_tasks
.iter()
.find(|t| t.uuid == *dep_uuid)
.map(|t| !t.completed)
.unwrap_or(false)
})
.collect()
}
pub fn urgency_score(&self, all_tasks: &[Task]) -> f32 {
if self.completed || self.is_deleted() {
return 0.0;
}
let mut score = 0.0_f32;
score += match self.priority {
Priority::High => 6.0,
Priority::Medium => 3.0,
Priority::Low => 1.0,
};
if let Some(due) = self.due_date {
let today = chrono::Local::now().naive_local().date();
let days = (due - today).num_days();
if days < 0 {
score += 12.0; } else {
score += (10.0 / (days as f32 + 1.0)).min(10.0);
}
}
let is_blocking = all_tasks
.iter()
.any(|t| !t.completed && !t.is_deleted() && t.depends_on.contains(&self.uuid));
if is_blocking {
score += 8.0
}
if self.is_blocked(all_tasks) {
score -= 5.0;
}
let age_days = (chrono::Utc::now() - self.created_at).num_days();
score += (age_days.max(0) as f32).ln().clamp(0.0, 2.0);
score += (self.tags.len() as f32 * 0.5).min(1.0);
score.max(0.0)
}
pub fn create_next_recurrence(&self, parent_uuid: Uuid) -> Option<Task> {
let recurrence = self.recurrence?;
let current_due = self.due_date?;
let next_due = recurrence.next_date(current_due);
let mut next_task = Task::new(
self.text.clone(),
self.priority,
self.tags.clone(),
self.project_id,
Some(next_due),
Some(recurrence),
);
next_task.parent_id = Some(parent_uuid);
Some(next_task)
}
#[allow(dead_code)]
pub fn is_recurring(&self) -> bool {
self.recurrence.is_some()
}
}
pub fn count_by_project(tasks: &[Task], project_uuid: uuid::Uuid) -> (usize, usize) {
let matching: Vec<_> = tasks
.iter()
.filter(|t| !t.is_deleted() && t.project_id == Some(project_uuid))
.collect();
let total = matching.len();
let done = matching.iter().filter(|t| t.completed).count();
(total, done)
}
pub(crate) fn detect_cycle(
tasks: &[Task],
task_uuid: Uuid,
new_dep_uuid: Uuid,
) -> Result<(), String> {
let mut visited = std::collections::HashSet::new();
let mut stack = vec![new_dep_uuid];
while let Some(current_uuid) = stack.pop() {
if current_uuid == task_uuid {
let task_num = tasks
.iter()
.position(|t| t.uuid == task_uuid)
.map(|i| i + 1)
.unwrap_or(0);
let dep_num = tasks
.iter()
.position(|t| t.uuid == new_dep_uuid)
.map(|i| i + 1)
.unwrap_or(0);
return Err(format!(
"Adding this dependency would create a cycle: \
task #{} → task #{} → ... → task #{}",
task_num, dep_num, task_num
));
}
if visited.insert(current_uuid)
&& let Some(t) = tasks.iter().find(|t| t.uuid == current_uuid)
{
for &d in &t.depends_on {
stack.push(d);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_task(text: &str) -> Task {
Task::new(text.to_string(), Priority::Medium, vec![], None, None, None)
}
#[test]
fn test_is_blocked_no_deps() {
let task = make_task("A");
assert!(!task.is_blocked(&[]));
}
#[test]
fn test_is_blocked_pending_dep() {
let dep = make_task("Dep");
let dep_uuid = dep.uuid;
let mut task = make_task("Task");
task.depends_on = vec![dep_uuid];
assert!(task.is_blocked(&[dep]));
}
#[test]
fn test_is_blocked_completed_dep() {
let mut dep = make_task("Dep");
let dep_uuid = dep.uuid;
dep.completed = true;
let mut task = make_task("Task");
task.depends_on = vec![dep_uuid];
assert!(!task.is_blocked(&[dep]));
}
#[test]
fn test_detect_cycle_direct() {
let mut tasks = vec![make_task("A"), make_task("B")];
tasks[0].depends_on = vec![tasks[1].uuid];
let result = detect_cycle(&tasks, tasks[1].uuid, tasks[0].uuid);
assert!(result.is_err());
}
#[test]
fn test_detect_no_cycle() {
let tasks = vec![make_task("A"), make_task("B"), make_task("C")];
let result = detect_cycle(&tasks, tasks[2].uuid, tasks[0].uuid);
assert!(result.is_ok());
}
#[test]
fn test_detect_transitive_cycle() {
let mut tasks = vec![make_task("A"), make_task("B"), make_task("C")];
tasks[0].depends_on = vec![tasks[1].uuid];
tasks[1].depends_on = vec![tasks[2].uuid];
let result = detect_cycle(&tasks, tasks[2].uuid, tasks[0].uuid);
assert!(result.is_err());
}
#[test]
fn test_blocking_deps_returns_pending_only() {
let mut dep1 = make_task("Dep1");
dep1.completed = true;
let dep1_uuid = dep1.uuid;
let dep2 = make_task("Dep2");
let dep2_uuid = dep2.uuid;
let mut task = make_task("Task");
task.depends_on = vec![dep1_uuid, dep2_uuid];
let blocking = task.blocking_deps(&[dep1, dep2]);
assert_eq!(blocking, vec![dep2_uuid]);
}
#[test]
fn test_updated_at_set_on_new() {
let task = make_task("A");
assert!(task.updated_at.is_some());
}
#[test]
fn test_deleted_at_none_on_new() {
let task = make_task("A");
assert!(task.deleted_at.is_none());
assert!(!task.is_deleted());
}
#[test]
fn test_soft_delete_sets_deleted_at() {
let mut task = make_task("A");
assert!(!task.is_deleted());
task.soft_delete();
assert!(task.is_deleted());
assert!(task.deleted_at.is_some());
}
#[test]
fn test_soft_delete_also_updates_updated_at() {
let mut task = make_task("A");
let before = task.updated_at;
std::thread::sleep(std::time::Duration::from_millis(5));
task.soft_delete();
assert!(task.updated_at > before);
}
#[test]
fn test_soft_delete_deleted_at_lte_updated_at() {
let mut task = make_task("A");
task.soft_delete();
assert!(task.updated_at >= task.deleted_at);
}
#[test]
fn test_touch_updates_timestamp() {
let mut task = make_task("A");
let before = task.updated_at;
std::thread::sleep(std::time::Duration::from_millis(5));
task.touch();
assert!(task.updated_at > before);
}
#[test]
fn test_mark_done_updates_timestamp() {
let mut task = make_task("A");
let before = task.updated_at;
std::thread::sleep(std::time::Duration::from_millis(5));
task.mark_done();
assert!(task.updated_at > before);
}
#[test]
fn test_mark_undone_updates_timestamp() {
let mut task = make_task("A");
task.mark_done();
let before = task.updated_at;
std::thread::sleep(std::time::Duration::from_millis(5));
task.mark_undone();
assert!(task.updated_at > before);
}
fn make_recurring(recurrence: Option<Recurrence>, due: Option<NaiveDate>) -> Task {
Task::new(
"Test".to_string(),
Priority::Medium,
vec![],
None,
due,
recurrence,
)
}
#[test]
fn test_daily_recurrence() {
let date = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap();
let task = make_recurring(Some(Recurrence::Daily), Some(date));
let parent_uuid = task.uuid;
let next = task.create_next_recurrence(parent_uuid).unwrap();
assert_eq!(
next.due_date,
Some(NaiveDate::from_ymd_opt(2026, 2, 11).unwrap())
);
assert_eq!(next.parent_id, Some(parent_uuid));
}
#[test]
fn test_weekly_recurrence() {
let date = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap();
let task = make_recurring(Some(Recurrence::Weekly), Some(date));
let parent_uuid = task.uuid;
let next = task.create_next_recurrence(parent_uuid).unwrap();
assert_eq!(
next.due_date,
Some(NaiveDate::from_ymd_opt(2026, 2, 17).unwrap())
);
}
#[test]
fn test_monthly_recurrence() {
let date = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap();
let task = make_recurring(Some(Recurrence::Monthly), Some(date));
let parent_uuid = task.uuid;
let next = task.create_next_recurrence(parent_uuid).unwrap();
assert_eq!(
next.due_date,
Some(NaiveDate::from_ymd_opt(2026, 3, 10).unwrap())
);
}
#[test]
fn test_monthly_boundary_case() {
let date = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
let task = make_recurring(Some(Recurrence::Monthly), Some(date));
let parent_uuid = task.uuid;
let next = task.create_next_recurrence(parent_uuid).unwrap();
assert_eq!(
next.due_date,
Some(NaiveDate::from_ymd_opt(2026, 2, 28).unwrap())
);
}
#[test]
fn test_no_recurrence_returns_none() {
let task = make_recurring(None, Some(NaiveDate::from_ymd_opt(2026, 2, 10).unwrap()));
let parent_uuid = task.uuid;
assert!(task.create_next_recurrence(parent_uuid).is_none());
}
#[test]
fn test_no_due_date_returns_none() {
let task = make_recurring(Some(Recurrence::Daily), None);
let parent_uuid = task.uuid;
assert!(task.create_next_recurrence(parent_uuid).is_none());
}
#[test]
fn test_recurrence_next_is_not_deleted() {
let date = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap();
let mut task = make_recurring(Some(Recurrence::Daily), Some(date));
task.soft_delete();
let parent_uuid = task.uuid;
let next = task.create_next_recurrence(parent_uuid).unwrap();
assert!(
!next.is_deleted(),
"next recurrence must not inherit deleted_at"
);
}
#[test]
fn test_project_preserved_in_recurrence() {
let date = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap();
let project_uuid = Uuid::new_v4();
let mut task = make_recurring(Some(Recurrence::Daily), Some(date));
task.project_id = Some(project_uuid);
let parent_uuid = task.uuid;
let next = task.create_next_recurrence(parent_uuid).unwrap();
assert_eq!(next.project_id, Some(project_uuid));
}
#[test]
fn test_deps_not_propagated_to_recurrence() {
let date = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap();
let mut task = make_recurring(Some(Recurrence::Daily), Some(date));
task.depends_on = vec![Uuid::new_v4(), Uuid::new_v4()];
let parent_uuid = task.uuid;
let next = task.create_next_recurrence(parent_uuid).unwrap();
assert!(
next.depends_on.is_empty(),
"recurrences should not inherit dependencies"
);
}
#[test]
fn test_count_by_project_basic() {
let project_uuid = Uuid::new_v4();
let other_uuid = Uuid::new_v4();
let mut t1 = Task::new(
"A".to_string(),
Priority::Medium,
vec![],
Some(project_uuid),
None,
None,
);
let t2 = Task::new(
"B".to_string(),
Priority::Medium,
vec![],
Some(project_uuid),
None,
None,
);
let _t3 = Task::new(
"C".to_string(),
Priority::Medium,
vec![],
Some(other_uuid),
None,
None,
);
t1.completed = true;
let tasks = vec![t1, t2, _t3];
let (total, done) = count_by_project(&tasks, project_uuid);
assert_eq!(total, 2);
assert_eq!(done, 1);
}
#[test]
fn test_count_by_project_excludes_deleted() {
let project_uuid = Uuid::new_v4();
let mut t1 = Task::new(
"A".to_string(),
Priority::Medium,
vec![],
Some(project_uuid),
None,
None,
);
t1.soft_delete();
let t2 = Task::new(
"B".to_string(),
Priority::Medium,
vec![],
Some(project_uuid),
None,
None,
);
let tasks = vec![t1, t2];
let (total, _) = count_by_project(&tasks, project_uuid);
assert_eq!(total, 1, "deleted tasks should not be counted");
}
#[test]
fn test_count_by_project_case_insensitive() {
let project_uuid = Uuid::new_v4();
let t1 = Task::new(
"A".to_string(),
Priority::Medium,
vec![],
Some(project_uuid),
None,
None,
);
let tasks = vec![t1];
let (total, _) = count_by_project(&tasks, project_uuid);
assert_eq!(total, 1);
}
}