1use crate::model::{Catalog, Episode};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum SortKey {
10 Published,
11 Title,
12 Duration,
13 Order,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SortDirection {
19 Ascending,
20 Descending,
21}
22
23pub fn matches(episode: &Episode, query: &str) -> bool {
28 matches_needle(episode, &needle(query))
29}
30
31fn needle(query: &str) -> String {
34 query.trim().to_lowercase()
35}
36
37fn matches_needle(episode: &Episode, needle: &str) -> bool {
38 if needle.is_empty() {
39 return true;
40 }
41
42 let optional = [&episode.slug, &episode.body, &episode.tracklist];
43 contains(&episode.title, needle)
44 || optional
45 .into_iter()
46 .flatten()
47 .any(|field| contains(field, needle))
48}
49
50fn contains(haystack: &str, lowercase_needle: &str) -> bool {
55 haystack.char_indices().any(|(offset, _)| {
56 let mut folded = haystack[offset..].chars().flat_map(char::to_lowercase);
57 let mut wanted = lowercase_needle.chars();
58 loop {
59 match (wanted.next(), folded.next()) {
60 (None, _) => return true,
61 (Some(_), None) => return false,
62 (Some(left), Some(right)) if left != right => return false,
63 _ => {}
64 }
65 }
66 })
67}
68
69pub fn search<'a>(catalog: &'a Catalog, query: &str) -> Vec<&'a Episode> {
71 let needle = needle(query);
72 catalog
73 .episodes
74 .iter()
75 .filter(|episode| matches_needle(episode, &needle))
76 .collect()
77}
78
79pub fn filter<'a>(episodes: &[&'a Episode], keep: impl Fn(&Episode) -> bool) -> Vec<&'a Episode> {
83 episodes
84 .iter()
85 .copied()
86 .filter(|episode| keep(episode))
87 .collect()
88}
89
90pub fn sort(episodes: &mut [&Episode], key: SortKey, direction: SortDirection) {
93 match key {
96 SortKey::Published => sort_by_key(episodes, direction, |episode| episode.published_at),
97 SortKey::Title => sort_by_key(episodes, direction, |episode| episode.title.to_lowercase()),
98 SortKey::Duration => sort_by_key(episodes, direction, |episode| episode.duration_secs),
99 SortKey::Order => sort_by_key(episodes, direction, |episode| episode.order),
100 }
101}
102
103fn sort_by_key<K: Ord>(
106 episodes: &mut [&Episode],
107 direction: SortDirection,
108 key: impl Fn(&Episode) -> K,
109) {
110 let mut decorated: Vec<(K, &Episode)> = episodes
111 .iter()
112 .map(|episode| (key(episode), *episode))
113 .collect();
114 decorated.sort_by(|left, right| {
115 let ordering = left.0.cmp(&right.0);
116 match direction {
117 SortDirection::Ascending => ordering,
118 SortDirection::Descending => ordering.reverse(),
119 }
120 });
121 for (slot, (_, episode)) in episodes.iter_mut().zip(decorated) {
122 *slot = episode;
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct Statistics {
129 pub episodes: usize,
130 pub tracks: usize,
132 pub duration_secs: u64,
137}
138
139pub fn statistics(catalog: &Catalog) -> Statistics {
142 Statistics {
143 episodes: catalog.episodes.len(),
144 tracks: catalog
146 .episodes
147 .iter()
148 .filter_map(|episode| episode.tracklist.as_deref())
149 .flat_map(str::lines)
150 .filter(|line| !line.trim().is_empty())
151 .count(),
152 duration_secs: catalog
153 .episodes
154 .iter()
155 .map(|episode| episode.duration_secs)
156 .sum(),
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 const FEED: &str = include_str!("../../tests/fixtures/rss.xml");
165 const BUNDLE: &str = include_str!("../../tests/fixtures/client.js");
166
167 fn enriched_catalog() -> Catalog {
168 let mut episodes = super::super::feed::parse(FEED).unwrap();
169 let enrichment = super::super::enrich::enrich_from(BUNDLE, &mut episodes);
170 Catalog {
171 info: enrichment.info,
172 episodes,
173 fetched_at: 0,
174 enriched: enrichment.applied,
175 }
176 }
177
178 fn episode(title: &str, published_at: i64, duration_secs: u64) -> Episode {
179 Episode {
180 bundle_title: None,
181 special: false,
182 title: title.into(),
183 link: String::new(),
184 enclosure_url: format!("https://datashat.net/{title}.mp3"),
185 byte_len: 0,
186 duration_secs,
187 published_at,
188 slug: None,
189 order: None,
190 tracklist: None,
191 body: None,
192 links: None,
193 }
194 }
195
196 fn titles(episodes: &[&Episode]) -> Vec<String> {
197 episodes
198 .iter()
199 .map(|episode| episode.title.clone())
200 .collect()
201 }
202
203 #[test]
204 fn a_query_matches_a_title_in_any_letter_case() {
205 let catalog = enriched_catalog();
206
207 let lower = titles(&search(&catalog, "corticyte"));
208 let upper = titles(&search(&catalog, "CORTICYTE"));
209 let mixed = titles(&search(&catalog, "CoRtIcYtE"));
210
211 assert!(lower.contains(&"Episode 79: Corticyte".to_owned()));
212 assert_eq!(lower, upper);
213 assert_eq!(lower, mixed);
214 }
215
216 #[test]
217 fn a_query_matches_a_title_whose_letters_are_not_ascii() {
218 let episode = episode("Épisode ÜBER Straße", 1, 1);
219
220 assert!(matches(&episode, "épisode"));
221 assert!(matches(&episode, "ÜBER"));
222 assert!(matches(&episode, "straße"));
223 assert!(!matches(&episode, "strasse"));
224 }
225
226 #[test]
227 fn every_episode_whose_title_carries_the_query_is_returned() {
228 let catalog = enriched_catalog();
229
230 let matched = titles(&search(&catalog, "seventy"));
231
232 assert_eq!(matched.len(), 10);
234 assert!(matched.contains(&"Episode 70: THINGS DISAPPEAR".to_owned()));
235 }
236
237 #[test]
238 fn a_query_matches_text_that_only_a_track_listing_carries() {
239 let catalog = enriched_catalog();
240
241 let matched = search(&catalog, "frog pocket");
242
243 assert!(!matched.is_empty());
244 for episode in &matched {
245 assert!(
246 !episode.title.to_lowercase().contains("frog pocket"),
247 "{} matched on its title, not its track listing",
248 episode.title
249 );
250 assert!(
251 episode
252 .tracklist
253 .as_deref()
254 .unwrap()
255 .to_lowercase()
256 .contains("frog pocket")
257 );
258 }
259 }
260
261 #[test]
262 fn searching_un_enriched_episodes_matches_on_the_title_and_does_not_fail() {
263 let catalog = Catalog {
264 info: Vec::new(),
265 episodes: vec![episode("Episode 79: Corticyte", 3, 10)],
266 fetched_at: 0,
267 enriched: false,
268 };
269
270 assert_eq!(search(&catalog, "corticyte").len(), 1);
271 assert_eq!(search(&catalog, "frog pocket").len(), 0);
272 }
273
274 #[test]
275 fn a_query_matching_nothing_returns_an_empty_result() {
276 let catalog = enriched_catalog();
277 assert!(search(&catalog, "zzzz no such text zzzz").is_empty());
278 }
279
280 #[test]
281 fn an_empty_query_returns_every_episode() {
282 let catalog = enriched_catalog();
283 assert_eq!(search(&catalog, "").len(), 79);
284 assert_eq!(search(&catalog, " ").len(), 79);
285 }
286
287 #[test]
288 fn a_query_matches_a_slug() {
289 let catalog = enriched_catalog();
290 assert_eq!(
291 titles(&search(&catalog, "seventynine")),
292 ["Episode 79: Corticyte"]
293 );
294 }
295
296 #[test]
297 fn search_preserves_the_catalogs_current_order() {
298 let catalog = enriched_catalog();
299 let matched = search(&catalog, "datassette");
300 let expected: Vec<String> = catalog
301 .episodes
302 .iter()
303 .filter(|episode| matches(episode, "datassette"))
304 .map(|episode| episode.title.clone())
305 .collect();
306 assert_eq!(titles(&matched), expected);
307 }
308
309 #[test]
310 fn sorting_by_publication_date_descending_puts_the_newest_first() {
311 let catalog = enriched_catalog();
312 let mut episodes = search(&catalog, "");
313
314 sort(&mut episodes, SortKey::Published, SortDirection::Descending);
315
316 assert_eq!(episodes.len(), 79);
317 assert_eq!(episodes[0].title, "Episode 79: Corticyte");
318 assert_eq!(episodes[78].title, "Episode 01: Datassette");
319 assert!(
320 episodes
321 .windows(2)
322 .all(|pair| pair[0].published_at >= pair[1].published_at)
323 );
324 }
325
326 #[test]
327 fn sorting_by_title_and_duration_covers_both_directions() {
328 let catalog = enriched_catalog();
329
330 for (key, direction) in [
331 (SortKey::Title, SortDirection::Ascending),
332 (SortKey::Title, SortDirection::Descending),
333 (SortKey::Duration, SortDirection::Ascending),
334 (SortKey::Duration, SortDirection::Descending),
335 (SortKey::Published, SortDirection::Ascending),
336 (SortKey::Order, SortDirection::Ascending),
337 (SortKey::Order, SortDirection::Descending),
338 ] {
339 let mut episodes = search(&catalog, "");
340 sort(&mut episodes, key, direction);
341 assert_eq!(episodes.len(), 79, "{key:?} {direction:?} dropped episodes");
342 }
343
344 let mut episodes = search(&catalog, "");
345 sort(&mut episodes, SortKey::Duration, SortDirection::Ascending);
346 assert!(
347 episodes
348 .windows(2)
349 .all(|pair| pair[0].duration_secs <= pair[1].duration_secs)
350 );
351
352 sort(&mut episodes, SortKey::Title, SortDirection::Ascending);
353 assert_eq!(episodes[0].title, "Episode 01: Datassette");
354 }
355
356 #[test]
357 fn sorting_is_stable_for_episodes_that_compare_equal() {
358 let all = [
359 episode("first", 10, 5),
360 episode("second", 10, 5),
361 episode("third", 10, 5),
362 ];
363 let mut episodes: Vec<&Episode> = all.iter().collect();
364
365 sort(&mut episodes, SortKey::Published, SortDirection::Ascending);
366 assert_eq!(titles(&episodes), ["first", "second", "third"]);
367
368 sort(&mut episodes, SortKey::Published, SortDirection::Descending);
369 assert_eq!(titles(&episodes), ["first", "second", "third"]);
370 }
371
372 #[test]
373 fn episodes_missing_the_sort_key_are_ordered_deterministically_not_dropped() {
374 let mut with_order = episode("enriched", 1, 1);
375 with_order.order = Some(7);
376 let all = [episode("bare-a", 1, 1), with_order, episode("bare-b", 1, 1)];
377 let mut episodes: Vec<&Episode> = all.iter().collect();
378
379 sort(&mut episodes, SortKey::Order, SortDirection::Ascending);
380 assert_eq!(titles(&episodes), ["bare-a", "bare-b", "enriched"]);
381
382 sort(&mut episodes, SortKey::Order, SortDirection::Descending);
383 assert_eq!(titles(&episodes), ["enriched", "bare-a", "bare-b"]);
384 }
385
386 #[test]
387 fn filtering_keeps_only_the_locally_available_episodes() {
388 let catalog = enriched_catalog();
389 let episodes = search(&catalog, "");
390 let downloaded = ["seventynine", "one", "two"];
391
392 let local = filter(&episodes, |episode| {
393 downloaded.contains(&episode.id().as_ref())
394 });
395
396 assert_eq!(local.len(), 3);
397 assert_eq!(
398 local.iter().map(|episode| episode.id()).collect::<Vec<_>>(),
399 ["seventynine", "two", "one"]
400 );
401 }
402
403 #[test]
404 fn the_totals_match_the_site_for_the_current_catalog() {
405 let catalog = enriched_catalog();
406
407 let statistics = statistics(&catalog);
408
409 assert_eq!(statistics.episodes, 79);
410 assert_eq!(statistics.tracks, 1380);
411 assert_eq!(statistics.duration_secs, 347_111);
412 assert_eq!(statistics.duration_secs / 3_600, 96);
414 assert_eq!(statistics.duration_secs % 3_600 / 60, 25);
415 assert_eq!(statistics.duration_secs % 60, 11);
416 }
417
418 #[test]
419 fn statistics_without_enrichment_report_no_tracks_and_still_count_and_sum() {
420 let catalog = Catalog {
421 info: Vec::new(),
422 episodes: vec![episode("one", 1, 100), episode("two", 2, 250)],
423 fetched_at: 0,
424 enriched: false,
425 };
426
427 let statistics = statistics(&catalog);
428
429 assert_eq!(statistics.tracks, 0);
430 assert_eq!(statistics.episodes, 2);
431 assert_eq!(statistics.duration_secs, 350);
432 }
433
434 #[test]
435 fn the_information_pages_are_available_and_change_no_count_or_navigation() {
436 let catalog = enriched_catalog();
437
438 assert_eq!(catalog.info_page("about").unwrap().title, "About");
439 assert_eq!(catalog.info_page("credits").unwrap().title, "Credits");
440 assert!(catalog.info_page("nothing").is_none());
441
442 assert_eq!(statistics(&catalog).episodes, 79);
444 assert_eq!(search(&catalog, "").len(), 79);
445 assert_eq!(catalog.episodes[0].title, "Episode 79: Corticyte");
446 assert!(catalog.get("about").is_none());
447 assert!(catalog.position("credits").is_none());
448 let position = catalog.position("sixtytwo").unwrap();
449 assert_eq!(catalog.episodes[position - 1].order, Some(63));
450 assert_eq!(catalog.episodes[position + 1].order, Some(61));
451 }
452
453 #[test]
454 fn filtering_with_nothing_downloaded_returns_nothing() {
455 let catalog = enriched_catalog();
456 let episodes = search(&catalog, "");
457 assert!(filter(&episodes, |_| false).is_empty());
458 assert_eq!(filter(&episodes, |_| true).len(), 79);
459 }
460}