Skip to main content

suno_core/
select.rs

1//! Pure clip selection and filtering.
2
3use std::cmp::Reverse;
4use std::collections::HashSet;
5
6use crate::model::Clip;
7
8/// A recency filter specification.
9pub enum RecencySpec {
10    /// Keep clips created within the last N seconds before `now`.
11    Relative(u64),
12    /// Keep clips created after the last-run timestamp.
13    LastRun,
14}
15
16impl RecencySpec {
17    /// Parse a spec string such as `"7d"`, `"2w"`, or `"last-run"`.
18    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
43/// Parameters for clip selection.
44pub struct SelectParams {
45    /// Keep at most the N most recent clips (by `created_at`).
46    pub limit: Option<usize>,
47    /// Keep only clips newer than this spec.
48    pub since: Option<RecencySpec>,
49    /// Always retain at least this many newest clips, regardless of the recency filter.
50    pub min_newest: usize,
51    /// Current Unix timestamp in seconds; used for relative recency specs.
52    pub now: u64,
53    /// Last-run Unix timestamp in seconds; used when `since` is `RecencySpec::LastRun`.
54    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
69/// Produce the final ordered selection from a slice of clips.
70///
71/// Deduplicates by ID (first occurrence wins), applies recency and limit
72/// filters, and enforces the min-newest floor. The original input order is
73/// always preserved in the output.
74pub 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 &params.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    // Indices into deduped sorted by clip_ts descending; computed once and
88    // reused by both the min-newest floor and the limit step.
89    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    // Apply recency filter. Clips with an unparseable timestamp are kept;
96    // they are not given an epoch timestamp that would make them a deletion candidate.
97    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    // Min-newest floor: when a recency threshold was active and fewer than
107    // min_newest clips passed it, pull in enough of the newest clips to meet
108    // the floor.
109    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    // Limit: keep only the N most recent. When a recency threshold is active the
116    // floor is authoritative, so the effective limit cannot drop below min_newest.
117    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
141/// Return the Unix timestamp (seconds) for a clip, or 0 if unparseable.
142fn clip_ts(clip: &Clip) -> u64 {
143    parse_timestamp(&clip.created_at).unwrap_or(0)
144}
145
146/// Parse an ISO 8601 UTC timestamp string to Unix seconds.
147///
148/// Accepts `YYYY-MM-DDTHH:MM:SS[.fff]Z`.
149fn 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    // --- parse_timestamp ---
178
179    #[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    // --- RecencySpec::parse ---
208
209    #[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    // --- select: deduplication ---
250
251    #[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    // --- select: order preservation ---
265
266    #[test]
267    fn preserves_original_order() {
268        // Clips are given newest-last; select must not reorder them.
269        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    // --- select: limit ---
282
283    #[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, &params);
295        // b (newest) and c should be kept, in original order a=0, b=1, c=2 -> b, c
296        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, &params).len(), 2);
312    }
313
314    // --- select: recency filter ---
315
316    #[test]
317    fn since_filters_old_clips() {
318        // now = 2024-01-10T00:00:00Z = 1704844800; threshold = now - 7d
319        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, &params);
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, &params);
349        assert_eq!(result.len(), 1);
350        assert_eq!(result[0].id, "new");
351    }
352
353    // --- select: min-newest floor ---
354
355    #[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        // All clips are older than the 1-day threshold; min_newest=1 should save the newest.
363        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, &params);
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, &params);
389        assert_eq!(result.len(), 2);
390        // b and c are the two newest; original order preserved -> b, c
391        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        // min_newest only kicks in when a since filter is active.
398        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, &params).len(), 2);
407    }
408
409    #[test]
410    fn min_newest_does_not_reduce_passing_set() {
411        // When the recency filter already keeps more than min_newest, the floor is a no-op.
412        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, &params).len(), 2);
424    }
425
426    // --- select: combined limit + recency + min-newest ---
427
428    #[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        // All 5 pass the 7-day threshold; min_newest=2, limit=3;
439        // effective_limit=max(3,2)=3 → e, d, c kept in original order.
440        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, &params);
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        // All fail recency; min_newest=3 but limit=1; floor must win -> all 3 kept.
462        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, &params);
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, &params);
484        assert_eq!(result.len(), 2);
485    }
486}