1use crate::adapters;
2use crate::content::PostInfo;
3use regex::Regex;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum SortField {
9 #[default]
11 Created,
12 Updated,
14 Title,
16 Status,
18}
19
20impl SortField {
21 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum SortOrder {
59 Asc,
61 #[default]
63 Desc,
64}
65
66impl SortOrder {
67 pub fn toggle(self) -> Self {
69 match self {
70 Self::Asc => Self::Desc,
71 Self::Desc => Self::Asc,
72 }
73 }
74
75 pub fn as_str(&self) -> &'static str {
77 match self {
78 Self::Asc => "asc",
79 Self::Desc => "desc",
80 }
81 }
82
83 pub fn arrow(&self) -> &'static str {
85 match self {
86 Self::Asc => "↑",
87 Self::Desc => "↓",
88 }
89 }
90}
91
92pub 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 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
114fn 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#[derive(Debug, Default)]
126pub struct PostFilter {
127 pub platform: Option<String>,
129 pub published: Option<bool>,
131 pub tag: Option<String>,
133 pub title_regex: Option<Regex>,
135}
136
137impl PostFilter {
138 pub fn matches(&self, info: &PostInfo) -> bool {
140 if let Some(ref platform) = self.platform
142 && !info.status.contains_key(platform)
143 {
144 return false;
145 }
146
147 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 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 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 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 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}