tasks-cli-rs 0.5.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
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();
            if !m.title.to_lowercase().contains(&needle)
                && !task.body.to_lowercase().contains(&needle)
            {
                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()));

        // tasks without a due date never match due filters
        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()));
    }
}