news-flash 3.0.2

Base library for a modern feed reader
Documentation
use std::collections::{HashMap, HashSet};

use crate::models::{ArticleID, ArticleOrder, CategoryID, FeedID, Marked, OrderBy, Read, TagID};
use chrono::{DateTime, Utc};

use super::{CategoryMapping, FeedMapping};

#[derive(Clone, Debug, Default)]
pub struct ArticleFilter {
    pub limit: Option<i64>,
    pub offset: Option<i64>,
    pub order: Option<ArticleOrder>,
    pub order_by: Option<OrderBy>,
    pub unread: Option<Read>,
    pub marked: Option<Marked>,
    pub feeds: Option<Vec<FeedID>>,
    pub feed_blacklist: Option<Vec<FeedID>>,
    pub categories: Option<Vec<CategoryID>>,
    pub category_blacklist: Option<Vec<CategoryID>>,
    pub tags: Option<Vec<TagID>>,
    pub ids: Option<Vec<ArticleID>>,
    pub newer_than: Option<DateTime<Utc>>,
    pub older_than: Option<DateTime<Utc>>,
    pub synced_before: Option<DateTime<Utc>>,
    pub synced_after: Option<DateTime<Utc>>,
    pub search_term: Option<String>,
}

impl ArticleFilter {
    pub fn ids(article_ids: Vec<ArticleID>) -> Self {
        Self {
            ids: Some(article_ids),
            ..Self::default()
        }
    }

    pub fn read_ids(article_ids: Vec<ArticleID>, read: Read) -> Self {
        Self {
            unread: Some(read),
            ids: Some(article_ids),
            ..Self::default()
        }
    }

    pub fn marked_ids(article_ids: Vec<ArticleID>, marked: Marked) -> Self {
        Self {
            marked: Some(marked),
            ids: Some(article_ids),
            ..Self::default()
        }
    }

    pub fn feed_unread(feed_id: &FeedID) -> Self {
        Self {
            unread: Some(Read::Unread),
            feeds: Some([feed_id.clone()].into()),
            ..Self::default()
        }
    }

    pub fn category_unread(category_id: &CategoryID) -> Self {
        Self {
            unread: Some(Read::Unread),
            categories: Some([category_id.clone()].into()),
            ..Self::default()
        }
    }

    pub fn tag_unread(tag_id: &TagID) -> Self {
        Self {
            unread: Some(Read::Unread),
            tags: Some([tag_id.clone()].into()),
            ..Self::default()
        }
    }

    pub fn all_unread() -> Self {
        Self {
            unread: Some(Read::Unread),
            ..Self::default()
        }
    }

    pub fn all_marked() -> Self {
        Self {
            marked: Some(Marked::Marked),
            ..Self::default()
        }
    }

    pub fn feeds_to_load(&self, category_mappings: &[CategoryMapping], feed_mappings: &[FeedMapping]) -> HashSet<FeedID> {
        let mut result = HashSet::new();

        if let Some(feeds) = &self.feeds {
            for feed_id in feeds {
                result.insert(feed_id.clone());
            }
        }

        if let Some(categories) = &self.categories {
            for category_id in categories {
                let feed_ids = Self::find_all_feeds(category_id, category_mappings, feed_mappings);
                for feed_id in feed_ids {
                    result.insert(feed_id);
                }
            }
        }

        result
    }

    pub fn feeds_to_blacklist(&self, category_mappings: &[CategoryMapping], feed_mappings: &[FeedMapping]) -> HashSet<FeedID> {
        let mut result = HashSet::new();

        if let Some(feed_blacklist) = &self.feed_blacklist {
            for feed_id in feed_blacklist {
                result.insert(feed_id.clone());
            }
        }

        if let Some(category_blacklist) = &self.category_blacklist {
            for category_id in category_blacklist {
                let feed_ids = Self::find_all_feeds(category_id, category_mappings, feed_mappings);
                for feed_id in feed_ids {
                    result.insert(feed_id);
                }
            }
        }

        result
    }

    fn find_all_feeds(category_id: &CategoryID, category_mappings: &[CategoryMapping], feed_mappings: &[FeedMapping]) -> HashSet<FeedID> {
        // Build lookup maps once: parent_id → children
        let mut feeds_by_parent: HashMap<&CategoryID, Vec<&FeedMapping>> = HashMap::new();
        for fm in feed_mappings {
            feeds_by_parent.entry(&fm.category_id).or_default().push(fm);
        }
        let mut cats_by_parent: HashMap<&CategoryID, Vec<&CategoryMapping>> = HashMap::new();
        for cm in category_mappings {
            cats_by_parent.entry(&cm.parent_id).or_default().push(cm);
        }

        fn find_all_feeds_impl(
            category_id: &CategoryID,
            feeds_by_parent: &HashMap<&CategoryID, Vec<&FeedMapping>>,
            cats_by_parent: &HashMap<&CategoryID, Vec<&CategoryMapping>>,
            visited: &mut HashSet<CategoryID>,
            result: &mut HashSet<FeedID>,
        ) {
            if !visited.insert(category_id.clone()) {
                tracing::warn!(%category_id, "Cycle detected in category hierarchy");
                return;
            }

            if let Some(feeds) = feeds_by_parent.get(category_id) {
                for fm in feeds {
                    result.insert(fm.feed_id.clone());
                }
            }

            if let Some(children) = cats_by_parent.get(category_id) {
                for cm in children {
                    find_all_feeds_impl(&cm.category_id, feeds_by_parent, cats_by_parent, visited, result);
                }
            }
        }

        let mut result = HashSet::new();
        let mut visited = HashSet::new();
        find_all_feeds_impl(category_id, &feeds_by_parent, &cats_by_parent, &mut visited, &mut result);
        result
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{CategoryID, FeedID};

    fn fm(feed: &str, category: &str) -> FeedMapping {
        FeedMapping {
            feed_id: FeedID::new(feed),
            category_id: CategoryID::new(category),
            sort_index: None,
        }
    }

    fn cm(parent: &str, child: &str) -> CategoryMapping {
        CategoryMapping {
            parent_id: CategoryID::new(parent),
            category_id: CategoryID::new(child),
            sort_index: None,
        }
    }

    #[test]
    fn find_all_feeds_flat() {
        // root
        // ├── feed1
        // └── feed2
        let feed_mappings = vec![fm("feed1", "root"), fm("feed2", "root"), fm("feed3", "other")];
        let category_mappings = vec![];

        let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);

        assert_eq!(result.len(), 2);
        assert!(result.contains(&FeedID::new("feed1")));
        assert!(result.contains(&FeedID::new("feed2")));
        assert!(!result.contains(&FeedID::new("feed3")));
    }

    #[test]
    fn find_all_feeds_nested() {
        // root
        // ├── feed1
        // └── child
        //     ├── feed2
        //     └── feed3
        let feed_mappings = vec![fm("feed1", "root"), fm("feed2", "child"), fm("feed3", "child")];
        let category_mappings = vec![cm("root", "child")];

        let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);

        assert_eq!(result.len(), 3);
        assert!(result.contains(&FeedID::new("feed1")));
        assert!(result.contains(&FeedID::new("feed2")));
        assert!(result.contains(&FeedID::new("feed3")));
    }

    #[test]
    fn find_all_feeds_deep_nesting() {
        // root
        // └── level1
        //     └── level2
        //         └── feed1
        let feed_mappings = vec![fm("feed1", "level2")];
        let category_mappings = vec![cm("root", "level1"), cm("level1", "level2")];

        let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);

        assert_eq!(result.len(), 1);
        assert!(result.contains(&FeedID::new("feed1")));
    }

    #[test]
    fn find_all_feeds_cycle_does_not_hang() {
        // root → child → root  (cycle!)
        let feed_mappings = vec![fm("feed1", "root"), fm("feed2", "child")];
        let category_mappings = vec![cm("root", "child"), cm("child", "root")];

        // Must terminate (not stack overflow) and still find feeds from both
        let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &category_mappings, &feed_mappings);

        assert!(result.contains(&FeedID::new("feed1")));
        assert!(result.contains(&FeedID::new("feed2")));
    }

    #[test]
    fn find_all_feeds_empty() {
        let result = ArticleFilter::find_all_feeds(&CategoryID::new("root"), &[], &[]);
        assert!(result.is_empty());
    }
}