Skip to main content

typub_engine/
sorting.rs

1use crate::adapters;
2use crate::content::PostInfo;
3use regex::Regex;
4use std::collections::HashMap;
5
6/// Field to sort posts by.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum SortField {
9    /// Sort by created date
10    #[default]
11    Created,
12    /// Sort by updated date (falls back to created if no updated date)
13    Updated,
14    /// Sort by title (alphabetical)
15    Title,
16    /// Sort by publish status (unpublished first)
17    Status,
18}
19
20impl SortField {
21    /// Cycle to the next sort field.
22    pub fn next(self) -> Self {
23        match self {
24            Self::Created => Self::Updated,
25            Self::Updated => Self::Title,
26            Self::Title => Self::Status,
27            Self::Status => Self::Created,
28        }
29    }
30
31    /// Display name for the sort field.
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            Self::Created => "created",
35            Self::Updated => "updated",
36            Self::Title => "title",
37            Self::Status => "status",
38        }
39    }
40}
41
42impl std::str::FromStr for SortField {
43    type Err = String;
44
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        match s.to_lowercase().as_str() {
47            "created" => Ok(Self::Created),
48            "updated" => Ok(Self::Updated),
49            "title" => Ok(Self::Title),
50            "status" => Ok(Self::Status),
51            _ => Err(format!("unknown sort field: {}", s)),
52        }
53    }
54}
55
56/// Sort order (ascending or descending).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum SortOrder {
59    /// Ascending order (A-Z, oldest first)
60    Asc,
61    /// Descending order (Z-A, newest first)
62    #[default]
63    Desc,
64}
65
66impl SortOrder {
67    /// Toggle between ascending and descending.
68    pub fn toggle(self) -> Self {
69        match self {
70            Self::Asc => Self::Desc,
71            Self::Desc => Self::Asc,
72        }
73    }
74
75    /// Display name for the sort order.
76    pub fn as_str(&self) -> &'static str {
77        match self {
78            Self::Asc => "asc",
79            Self::Desc => "desc",
80        }
81    }
82
83    /// Arrow indicator for UI.
84    pub fn arrow(&self) -> &'static str {
85        match self {
86            Self::Asc => "↑",
87            Self::Desc => "↓",
88        }
89    }
90}
91
92/// Sort TUI PostInfo items.
93pub fn sort_posts(posts: &mut [PostInfo], field: SortField, order: SortOrder) {
94    posts.sort_by(|a, b| {
95        let cmp = match field {
96            SortField::Created => a.created.cmp(&b.created),
97            SortField::Updated => a.updated.cmp(&b.updated),
98            SortField::Title => a.title.to_lowercase().cmp(&b.title.to_lowercase()),
99            SortField::Status => {
100                // Sort by unpublished count (more unpublished = earlier)
101                let a_unpub = count_unpublished(&a.status);
102                let b_unpub = count_unpublished(&b.status);
103                a_unpub.cmp(&b_unpub)
104            }
105        };
106
107        match order {
108            SortOrder::Asc => cmp,
109            SortOrder::Desc => cmp.reverse(),
110        }
111    });
112}
113
114/// Count unpublished API platforms for a post.
115fn count_unpublished(status: &HashMap<String, (bool, Option<String>)>) -> usize {
116    status
117        .iter()
118        .filter(|(platform, (published, _))| {
119            !adapters::is_local_output_platform(platform) && !published
120        })
121        .count()
122}
123
124/// Filter predicates for posts.
125#[derive(Debug, Default)]
126pub struct PostFilter {
127    /// Filter to posts configured for this platform.
128    pub platform: Option<String>,
129    /// Filter by publish status: true = fully published, false = has pending.
130    pub published: Option<bool>,
131    /// Filter by tag (case-insensitive).
132    pub tag: Option<String>,
133    /// Filter by title (regex match).
134    pub title_regex: Option<Regex>,
135}
136
137impl PostFilter {
138    /// Check if a post matches all active filters (using PostInfo).
139    pub fn matches(&self, info: &PostInfo) -> bool {
140        // Platform filter
141        if let Some(ref platform) = self.platform
142            && !info.status.contains_key(platform)
143        {
144            return false;
145        }
146
147        // Published/pending filter
148        if let Some(want_published) = self.published {
149            let is_fully_published = info
150                .status
151                .iter()
152                .filter(|(p, _)| !adapters::is_local_output_platform(p))
153                .all(|(_, (pub_status, _))| *pub_status);
154
155            if want_published != is_fully_published {
156                return false;
157            }
158        }
159
160        // Tag filter (case-insensitive)
161        if let Some(ref tag) = self.tag {
162            let tag_lower = tag.to_lowercase();
163            let has_tag = info.tags.iter().any(|t| t.to_lowercase() == tag_lower);
164            if !has_tag {
165                return false;
166            }
167        }
168
169        // Title regex filter
170        if let Some(ref regex) = self.title_regex
171            && !regex.is_match(&info.title)
172        {
173            return false;
174        }
175
176        true
177    }
178
179    /// Check if any filter is active.
180    pub fn is_active(&self) -> bool {
181        self.platform.is_some()
182            || self.published.is_some()
183            || self.tag.is_some()
184            || self.title_regex.is_some()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    #![allow(clippy::expect_used)]
191
192    use super::*;
193    use chrono::NaiveDate;
194    use std::path::PathBuf;
195
196    fn make_post_info(
197        title: &str,
198        created: NaiveDate,
199        updated: NaiveDate,
200        published_count: usize,
201        total_count: usize,
202    ) -> PostInfo {
203        let mut status = HashMap::new();
204        for i in 0..total_count {
205            let name = format!("platform{}", i);
206            let is_published = i < published_count;
207            status.insert(name, (is_published, None));
208        }
209        PostInfo {
210            path: PathBuf::from("/tmp/test"),
211            title: title.to_string(),
212            slug: title.to_lowercase().replace(' ', "-"),
213            created,
214            updated,
215            tags: vec![],
216            status,
217        }
218    }
219
220    #[test]
221    fn test_sort_by_created_desc() {
222        let old_date = NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid");
223        let new_date = NaiveDate::from_ymd_opt(2024, 6, 1).expect("valid");
224        let mut posts = vec![
225            make_post_info("Old", old_date, old_date, 0, 1),
226            make_post_info("New", new_date, new_date, 0, 1),
227        ];
228
229        sort_posts(&mut posts, SortField::Created, SortOrder::Desc);
230        assert_eq!(posts[0].title, "New");
231        assert_eq!(posts[1].title, "Old");
232    }
233
234    #[test]
235    fn test_sort_by_title_asc() {
236        let date = NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid");
237        let mut posts = vec![
238            make_post_info("Zebra", date, date, 0, 1),
239            make_post_info("Apple", date, date, 0, 1),
240        ];
241
242        sort_posts(&mut posts, SortField::Title, SortOrder::Asc);
243        assert_eq!(posts[0].title, "Apple");
244        assert_eq!(posts[1].title, "Zebra");
245    }
246
247    #[test]
248    fn test_sort_by_status_unpublished_first() {
249        let date = NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid");
250        let mut posts = vec![
251            make_post_info("Published", date, date, 2, 2),
252            make_post_info("Pending", date, date, 0, 2),
253        ];
254
255        // Desc order = more unpublished first
256        sort_posts(&mut posts, SortField::Status, SortOrder::Desc);
257        assert_eq!(posts[0].title, "Pending");
258        assert_eq!(posts[1].title, "Published");
259    }
260
261    #[test]
262    fn test_sort_field_cycle() {
263        assert_eq!(SortField::Created.next(), SortField::Updated);
264        assert_eq!(SortField::Updated.next(), SortField::Title);
265        assert_eq!(SortField::Title.next(), SortField::Status);
266        assert_eq!(SortField::Status.next(), SortField::Created);
267    }
268}