use chrono::{DateTime, Utc};
use crate::model::{Priority, Status, Task};
#[derive(Debug, Default)]
pub struct Filter {
pub status: Option<Status>,
pub tag: Option<String>,
pub priority: Option<Priority>,
pub due_before: Option<DateTime<Utc>>,
pub due_after: Option<DateTime<Utc>>,
pub text: Option<String>,
}
impl Filter {
pub fn matches(&self, task: &Task) -> bool {
let m = &task.meta;
if let Some(s) = self.status
&& m.status != s
{
return false;
}
if let Some(tag) = &self.tag
&& !m.tags.iter().any(|t| t == tag)
{
return false;
}
if let Some(p) = self.priority
&& m.priority != p
{
return false;
}
if let Some(before) = self.due_before {
match m.due_date {
Some(due) if due <= before => {}
_ => return false,
}
}
if let Some(after) = self.due_after {
match m.due_date {
Some(due) if due >= after => {}
_ => return false,
}
}
if let Some(text) = &self.text {
let needle = text.to_lowercase();
let hit = m.title.to_lowercase().contains(&needle)
|| task.body.to_lowercase().contains(&needle)
|| m.description.as_ref().is_some_and(|d| d.to_lowercase().contains(&needle));
if !hit {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn task() -> Task {
let mut t = Task::new(1, "Fix login bug");
t.meta.tags = vec!["backend".into()];
t.meta.priority = Priority::High;
t.meta.due_date = Some(Utc.with_ymd_and_hms(2026, 8, 10, 0, 0, 0).unwrap());
t.body = "JWT token expires too early".into();
t
}
#[test]
fn empty_filter_matches_all() {
assert!(Filter::default().matches(&task()));
}
#[test]
fn status_filter() {
let f = Filter { status: Some(Status::Done), ..Default::default() };
assert!(!f.matches(&task()));
let f = Filter { status: Some(Status::Todo), ..Default::default() };
assert!(f.matches(&task()));
}
#[test]
fn tag_and_priority_filter() {
let f = Filter { tag: Some("backend".into()), priority: Some(Priority::High), ..Default::default() };
assert!(f.matches(&task()));
let f = Filter { tag: Some("frontend".into()), ..Default::default() };
assert!(!f.matches(&task()));
}
#[test]
fn due_range_filter() {
let before = Filter {
due_before: Some(Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap()),
..Default::default()
};
assert!(before.matches(&task()));
let before = Filter {
due_before: Some(Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap()),
..Default::default()
};
assert!(!before.matches(&task()));
let mut no_due = task();
no_due.meta.due_date = None;
assert!(!before.matches(&no_due));
}
#[test]
fn text_searches_title_and_body() {
let f = Filter { text: Some("LOGIN".into()), ..Default::default() };
assert!(f.matches(&task()));
let f = Filter { text: Some("jwt token".into()), ..Default::default() };
assert!(f.matches(&task()));
let f = Filter { text: Some("nonexistent".into()), ..Default::default() };
assert!(!f.matches(&task()));
}
#[test]
fn combined_filters_are_and() {
let f = Filter {
status: Some(Status::Todo),
tag: Some("backend".into()),
text: Some("login".into()),
..Default::default()
};
assert!(f.matches(&task()));
let f = Filter {
status: Some(Status::Done),
tag: Some("backend".into()),
..Default::default()
};
assert!(!f.matches(&task()));
}
}