1use std::cmp::Reverse;
4use std::collections::HashSet;
5
6use crate::model::Clip;
7
8pub enum RecencySpec {
10 Relative(u64),
12 LastRun,
14}
15
16impl RecencySpec {
17 pub fn parse(spec: &str) -> Result<Self, String> {
19 if spec == "last-run" {
20 return Ok(RecencySpec::LastRun);
21 }
22 let split = spec
23 .find(|c: char| !c.is_ascii_digit())
24 .unwrap_or(spec.len());
25 let (digits, unit) = spec.split_at(split);
26 let n: u64 = digits
27 .parse()
28 .map_err(|_| format!("invalid recency spec: {spec}"))?;
29 let secs = match unit {
30 "d" => n
31 .checked_mul(86_400)
32 .ok_or_else(|| format!("recency spec overflows: {spec}"))?,
33 "w" => n
34 .checked_mul(7)
35 .and_then(|v| v.checked_mul(86_400))
36 .ok_or_else(|| format!("recency spec overflows: {spec}"))?,
37 _ => return Err(format!("unknown unit in recency spec: {spec}")),
38 };
39 Ok(RecencySpec::Relative(secs))
40 }
41}
42
43pub struct SelectParams {
45 pub limit: Option<usize>,
47 pub since: Option<RecencySpec>,
49 pub min_newest: usize,
51 pub now: u64,
53 pub last_run: Option<u64>,
55}
56
57impl Default for SelectParams {
58 fn default() -> Self {
59 Self {
60 limit: None,
61 since: None,
62 min_newest: 1,
63 now: 0,
64 last_run: None,
65 }
66 }
67}
68
69pub fn select<'a>(clips: &'a [Clip], params: &SelectParams) -> Vec<&'a Clip> {
75 let mut seen: HashSet<&str> = HashSet::new();
76 let deduped: Vec<&Clip> = clips
77 .iter()
78 .filter(|c| seen.insert(c.id.as_str()))
79 .collect();
80
81 let threshold: Option<u64> = match ¶ms.since {
82 None => None,
83 Some(RecencySpec::Relative(secs)) => Some(params.now.saturating_sub(*secs)),
84 Some(RecencySpec::LastRun) => params.last_run,
85 };
86
87 let recency_order: Vec<usize> = {
90 let mut idx: Vec<usize> = (0..deduped.len()).collect();
91 idx.sort_by_cached_key(|&i| Reverse(clip_ts(deduped[i])));
92 idx
93 };
94
95 let mut keep: HashSet<&str> = match threshold {
98 Some(t) => deduped
99 .iter()
100 .filter(|c| parse_timestamp(&c.created_at).is_none_or(|ts| ts > t))
101 .map(|c| c.id.as_str())
102 .collect(),
103 None => deduped.iter().map(|c| c.id.as_str()).collect(),
104 };
105
106 if threshold.is_some() && keep.len() < params.min_newest {
110 for &i in recency_order.iter().take(params.min_newest) {
111 keep.insert(deduped[i].id.as_str());
112 }
113 }
114
115 let effective_limit = params.limit.map(|n| {
118 if threshold.is_some() {
119 n.max(params.min_newest)
120 } else {
121 n
122 }
123 });
124 if let Some(n) = effective_limit
125 && keep.len() > n
126 {
127 keep = recency_order
128 .iter()
129 .filter(|&&i| keep.contains(deduped[i].id.as_str()))
130 .take(n)
131 .map(|&i| deduped[i].id.as_str())
132 .collect();
133 }
134
135 deduped
136 .into_iter()
137 .filter(|c| keep.contains(c.id.as_str()))
138 .collect()
139}
140
141fn clip_ts(clip: &Clip) -> u64 {
143 parse_timestamp(&clip.created_at).unwrap_or(0)
144}
145
146fn parse_timestamp(s: &str) -> Option<u64> {
150 let s = s.strip_suffix('Z')?;
151 let (date, time) = s.split_once('T')?;
152 let time = time.split_once('.').map_or(time, |(t, _)| t);
153 let mut dp = date.split('-');
154 let year: u32 = dp.next()?.parse().ok()?;
155 let month: u32 = dp.next()?.parse().ok()?;
156 let day: u32 = dp.next()?.parse().ok()?;
157 let mut tp = time.split(':');
158 let hour: u64 = tp.next()?.parse().ok()?;
159 let minute: u64 = tp.next()?.parse().ok()?;
160 let second: u64 = tp.next()?.parse().ok()?;
161 let days = crate::civil::civil_to_days(year, month, day)?;
162 Some(days * 86_400 + hour * 3_600 + minute * 60 + second)
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn clip(id: &str, created_at: &str) -> Clip {
170 Clip {
171 id: id.to_string(),
172 created_at: created_at.to_string(),
173 ..Default::default()
174 }
175 }
176
177 #[test]
180 fn parse_timestamp_epoch() {
181 assert_eq!(parse_timestamp("1970-01-01T00:00:00Z"), Some(0));
182 }
183
184 #[test]
185 fn parse_timestamp_one_day() {
186 assert_eq!(parse_timestamp("1970-01-02T00:00:00Z"), Some(86_400));
187 }
188
189 #[test]
190 fn parse_timestamp_with_millis() {
191 assert_eq!(
192 parse_timestamp("2024-01-15T08:30:00.000Z"),
193 parse_timestamp("2024-01-15T08:30:00Z")
194 );
195 }
196
197 #[test]
198 fn parse_timestamp_missing_z_returns_none() {
199 assert!(parse_timestamp("2024-01-15T08:30:00").is_none());
200 }
201
202 #[test]
203 fn parse_timestamp_empty_returns_none() {
204 assert!(parse_timestamp("").is_none());
205 }
206
207 #[test]
210 fn parse_recency_days() {
211 let RecencySpec::Relative(secs) = RecencySpec::parse("7d").unwrap() else {
212 panic!("expected Relative");
213 };
214 assert_eq!(secs, 7 * 86_400);
215 }
216
217 #[test]
218 fn parse_recency_weeks() {
219 let RecencySpec::Relative(secs) = RecencySpec::parse("2w").unwrap() else {
220 panic!("expected Relative");
221 };
222 assert_eq!(secs, 2 * 7 * 86_400);
223 }
224
225 #[test]
226 fn parse_recency_last_run() {
227 assert!(matches!(
228 RecencySpec::parse("last-run").unwrap(),
229 RecencySpec::LastRun
230 ));
231 }
232
233 #[test]
234 fn parse_recency_invalid_unit() {
235 assert!(RecencySpec::parse("3x").is_err());
236 }
237
238 #[test]
239 fn parse_recency_invalid_number() {
240 assert!(RecencySpec::parse("wd").is_err());
241 }
242
243 #[test]
244 fn parse_recency_overflow_returns_error() {
245 assert!(RecencySpec::parse(&format!("{}d", u64::MAX)).is_err());
246 assert!(RecencySpec::parse(&format!("{}w", u64::MAX)).is_err());
247 }
248
249 #[test]
252 fn dedup_keeps_first_occurrence() {
253 let clips = vec![
254 clip("a", "2024-01-01T00:00:00Z"),
255 clip("b", "2024-01-02T00:00:00Z"),
256 clip("a", "2024-01-03T00:00:00Z"),
257 ];
258 let result = select(&clips, &SelectParams::default());
259 assert_eq!(result.len(), 2);
260 assert_eq!(result[0].id, "a");
261 assert_eq!(result[1].id, "b");
262 }
263
264 #[test]
267 fn preserves_original_order() {
268 let clips = vec![
270 clip("a", "2024-01-03T00:00:00Z"),
271 clip("b", "2024-01-01T00:00:00Z"),
272 clip("c", "2024-01-02T00:00:00Z"),
273 ];
274 let result = select(&clips, &SelectParams::default());
275 assert_eq!(
276 result.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
277 ["a", "b", "c"]
278 );
279 }
280
281 #[test]
284 fn limit_keeps_n_most_recent() {
285 let clips = vec![
286 clip("a", "2024-01-01T00:00:00Z"),
287 clip("b", "2024-01-03T00:00:00Z"),
288 clip("c", "2024-01-02T00:00:00Z"),
289 ];
290 let params = SelectParams {
291 limit: Some(2),
292 ..Default::default()
293 };
294 let result = select(&clips, ¶ms);
295 assert_eq!(result.len(), 2);
297 assert_eq!(result[0].id, "b");
298 assert_eq!(result[1].id, "c");
299 }
300
301 #[test]
302 fn limit_larger_than_set_keeps_all() {
303 let clips = vec![
304 clip("a", "2024-01-01T00:00:00Z"),
305 clip("b", "2024-01-02T00:00:00Z"),
306 ];
307 let params = SelectParams {
308 limit: Some(10),
309 ..Default::default()
310 };
311 assert_eq!(select(&clips, ¶ms).len(), 2);
312 }
313
314 #[test]
317 fn since_filters_old_clips() {
318 let now = parse_timestamp("2024-01-10T00:00:00Z").unwrap();
320 let clips = vec![
321 clip("old", "2024-01-01T00:00:00Z"),
322 clip("new", "2024-01-05T00:00:00Z"),
323 ];
324 let params = SelectParams {
325 since: Some(RecencySpec::Relative(7 * 86_400)),
326 min_newest: 0,
327 now,
328 ..Default::default()
329 };
330 let result = select(&clips, ¶ms);
331 assert_eq!(result.len(), 1);
332 assert_eq!(result[0].id, "new");
333 }
334
335 #[test]
336 fn since_last_run_uses_supplied_timestamp() {
337 let last_run = parse_timestamp("2024-01-05T00:00:00Z").unwrap();
338 let clips = vec![
339 clip("old", "2024-01-04T00:00:00Z"),
340 clip("new", "2024-01-06T00:00:00Z"),
341 ];
342 let params = SelectParams {
343 since: Some(RecencySpec::LastRun),
344 min_newest: 0,
345 last_run: Some(last_run),
346 ..Default::default()
347 };
348 let result = select(&clips, ¶ms);
349 assert_eq!(result.len(), 1);
350 assert_eq!(result[0].id, "new");
351 }
352
353 #[test]
356 fn min_newest_floor_prevents_empty_selection() {
357 let now = parse_timestamp("2024-01-10T00:00:00Z").unwrap();
358 let clips = vec![
359 clip("a", "2024-01-01T00:00:00Z"),
360 clip("b", "2024-01-02T00:00:00Z"),
361 ];
362 let params = SelectParams {
364 since: Some(RecencySpec::Relative(86_400)),
365 min_newest: 1,
366 now,
367 ..Default::default()
368 };
369 let result = select(&clips, ¶ms);
370 assert_eq!(result.len(), 1);
371 assert_eq!(result[0].id, "b");
372 }
373
374 #[test]
375 fn min_newest_floor_keeps_n_when_all_filtered() {
376 let now = parse_timestamp("2024-01-10T00:00:00Z").unwrap();
377 let clips = vec![
378 clip("a", "2024-01-01T00:00:00Z"),
379 clip("b", "2024-01-02T00:00:00Z"),
380 clip("c", "2024-01-03T00:00:00Z"),
381 ];
382 let params = SelectParams {
383 since: Some(RecencySpec::Relative(86_400)),
384 min_newest: 2,
385 now,
386 ..Default::default()
387 };
388 let result = select(&clips, ¶ms);
389 assert_eq!(result.len(), 2);
390 let ids: Vec<&str> = result.iter().map(|c| c.id.as_str()).collect();
392 assert_eq!(ids, ["b", "c"]);
393 }
394
395 #[test]
396 fn min_newest_not_applied_without_recency_filter() {
397 let clips = vec![
399 clip("a", "2024-01-01T00:00:00Z"),
400 clip("b", "2024-01-02T00:00:00Z"),
401 ];
402 let params = SelectParams {
403 min_newest: 5,
404 ..Default::default()
405 };
406 assert_eq!(select(&clips, ¶ms).len(), 2);
407 }
408
409 #[test]
410 fn min_newest_does_not_reduce_passing_set() {
411 let now = parse_timestamp("2024-01-10T00:00:00Z").unwrap();
413 let clips = vec![
414 clip("a", "2024-01-08T00:00:00Z"),
415 clip("b", "2024-01-09T00:00:00Z"),
416 ];
417 let params = SelectParams {
418 since: Some(RecencySpec::Relative(7 * 86_400)),
419 min_newest: 1,
420 now,
421 ..Default::default()
422 };
423 assert_eq!(select(&clips, ¶ms).len(), 2);
424 }
425
426 #[test]
429 fn limit_trims_when_above_min_newest() {
430 let now = parse_timestamp("2024-01-10T00:00:00Z").unwrap();
431 let clips = vec![
432 clip("a", "2024-01-04T00:00:00Z"),
433 clip("b", "2024-01-05T00:00:00Z"),
434 clip("c", "2024-01-06T00:00:00Z"),
435 clip("d", "2024-01-07T00:00:00Z"),
436 clip("e", "2024-01-08T00:00:00Z"),
437 ];
438 let params = SelectParams {
441 since: Some(RecencySpec::Relative(7 * 86_400)),
442 min_newest: 2,
443 limit: Some(3),
444 now,
445 ..Default::default()
446 };
447 let result = select(&clips, ¶ms);
448 assert_eq!(result.len(), 3);
449 let ids: Vec<&str> = result.iter().map(|c| c.id.as_str()).collect();
450 assert_eq!(ids, ["c", "d", "e"]);
451 }
452
453 #[test]
454 fn limit_below_min_newest_is_clamped_to_floor() {
455 let now = parse_timestamp("2024-01-10T00:00:00Z").unwrap();
456 let clips = vec![
457 clip("a", "2024-01-01T00:00:00Z"),
458 clip("b", "2024-01-02T00:00:00Z"),
459 clip("c", "2024-01-03T00:00:00Z"),
460 ];
461 let params = SelectParams {
463 since: Some(RecencySpec::Relative(86_400)),
464 min_newest: 3,
465 limit: Some(1),
466 now,
467 ..Default::default()
468 };
469 let result = select(&clips, ¶ms);
470 assert_eq!(result.len(), 3);
471 }
472
473 #[test]
474 fn unparseable_timestamp_is_kept_through_recency_filter() {
475 let now = parse_timestamp("2024-01-10T00:00:00Z").unwrap();
476 let clips = vec![clip("good", "2024-01-09T00:00:00Z"), clip("bad_ts", "")];
477 let params = SelectParams {
478 since: Some(RecencySpec::Relative(7 * 86_400)),
479 min_newest: 0,
480 now,
481 ..Default::default()
482 };
483 let result = select(&clips, ¶ms);
484 assert_eq!(result.len(), 2);
485 }
486}