nextest_runner/record/
retention.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Retention policies and pruning for recorded test runs.
5//!
6//! This module provides the [`RecordRetentionPolicy`] type for configuring
7//! retention limits, and functions for determining which runs should be deleted
8//! to enforce those limits.
9
10use super::{
11    display::{DisplayPrunePlan, DisplayPruneResult, Styles},
12    run_id_index::RunIdIndex,
13    store::StoreRunsDir,
14};
15use crate::{
16    errors::RecordPruneError, record::RecordedRunInfo, redact::Redactor,
17    user_config::elements::RecordConfig,
18};
19use bytesize::ByteSize;
20use chrono::{DateTime, TimeDelta, Utc};
21use quick_junit::ReportUuid;
22use std::{collections::HashSet, time::Duration};
23
24/// A retention policy for recorded test runs.
25///
26/// The policy enforces limits on the number of runs, total size, and age.
27/// All limits are optional; if unset, that dimension is not limited.
28///
29/// When pruning, runs are evaluated in order from most recently used to least
30/// recently used (by `last_written_at`). A run is kept if it satisfies all
31/// three conditions:
32///
33/// 1. The total count of kept runs is below `max_count`.
34/// 2. The cumulative size of kept runs is below `max_total_size`.
35/// 3. The run was used more recently than `max_age`.
36///
37/// Incomplete runs are treated the same as complete runs for retention purposes.
38#[derive(Clone, Debug, Default)]
39pub struct RecordRetentionPolicy {
40    /// Maximum number of runs to keep.
41    pub max_count: Option<usize>,
42
43    /// Maximum total size of all runs in bytes.
44    pub max_total_size: Option<ByteSize>,
45
46    /// Maximum age of runs to keep.
47    pub max_age: Option<Duration>,
48}
49
50impl RecordRetentionPolicy {
51    /// Computes which runs should be deleted according to this policy.
52    ///
53    /// Returns a list of run IDs that should be deleted. The order of the
54    /// returned IDs is not specified.
55    ///
56    /// The `now` parameter is used to calculate run ages. Typically this should
57    /// be `Utc::now()`.
58    pub(crate) fn compute_runs_to_delete(
59        &self,
60        runs: &[RecordedRunInfo],
61        now: DateTime<Utc>,
62    ) -> Vec<ReportUuid> {
63        // Sort by last_written_at (most recently used first) for LRU eviction.
64        let mut sorted_runs: Vec<_> = runs.iter().collect();
65        sorted_runs.sort_by(|a, b| b.last_written_at.cmp(&a.last_written_at));
66
67        let mut to_delete = Vec::new();
68        let mut kept_count = 0usize;
69        let mut kept_size = 0u64;
70
71        for run in sorted_runs {
72            let mut should_delete = false;
73
74            if let Some(max_count) = self.max_count
75                && kept_count >= max_count
76            {
77                should_delete = true;
78            }
79
80            if let Some(max_total_size) = self.max_total_size
81                && kept_size + run.sizes.total_compressed() > max_total_size.as_u64()
82            {
83                should_delete = true;
84            }
85
86            if let Some(max_age) = self.max_age {
87                // Use signed_duration_since and saturate negative values to zero. A
88                // negative age can occur if the system clock moved backward between
89                // recording and pruning; treat such runs as "just used".
90                let time_since_last_use = now
91                    .signed_duration_since(run.last_written_at)
92                    .max(TimeDelta::zero());
93                if time_since_last_use > TimeDelta::from_std(max_age).unwrap_or(TimeDelta::MAX) {
94                    should_delete = true;
95                }
96            }
97
98            if should_delete {
99                to_delete.push(run.run_id);
100            } else {
101                kept_count += 1;
102                kept_size += run.sizes.total_compressed();
103            }
104        }
105
106        to_delete
107    }
108
109    /// Checks if any retention limit is exceeded by the given factor.
110    ///
111    /// Returns `true` if:
112    /// - The run count exceeds `factor * max_count`, OR
113    /// - The total size exceeds `factor * max_total_size`.
114    ///
115    /// Age limits are not checked here; they are handled by daily pruning.
116    ///
117    /// This is used to trigger pruning when limits are significantly exceeded,
118    /// even if the daily prune interval hasn't elapsed.
119    pub(crate) fn limits_exceeded_by_factor(&self, runs: &[RecordedRunInfo], factor: f64) -> bool {
120        // Check count limit.
121        if let Some(max_count) = self.max_count {
122            let threshold = (max_count as f64 * factor) as usize;
123            if runs.len() > threshold {
124                return true;
125            }
126        }
127
128        // Check size limit.
129        if let Some(max_total_size) = self.max_total_size {
130            let total_size: u64 = runs.iter().map(|r| r.sizes.total_compressed()).sum();
131            let threshold = (max_total_size.as_u64() as f64 * factor) as u64;
132            if total_size > threshold {
133                return true;
134            }
135        }
136
137        false
138    }
139}
140
141impl From<&RecordConfig> for RecordRetentionPolicy {
142    fn from(config: &RecordConfig) -> Self {
143        Self {
144            max_count: Some(config.max_records),
145            max_total_size: Some(config.max_total_size),
146            max_age: Some(config.max_age),
147        }
148    }
149}
150
151/// Whether pruning was explicit (user-requested) or implicit (automatic).
152#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
153pub enum PruneKind {
154    /// Explicit pruning via `cargo nextest store prune`.
155    #[default]
156    Explicit,
157
158    /// Implicit pruning during recording (via `prune_if_needed`).
159    Implicit,
160}
161
162/// The result of a pruning operation.
163#[derive(Debug, Default)]
164pub struct PruneResult {
165    /// Whether this was explicit or implicit pruning.
166    pub kind: PruneKind,
167
168    /// Number of runs that were deleted.
169    pub deleted_count: usize,
170
171    /// Number of orphaned directories that were deleted.
172    ///
173    /// Orphaned directories are run directories that exist on disk but are not
174    /// tracked in `runs.json.zst`. This can happen if a process crashes or is killed
175    /// after creating a run directory but before recording completes.
176    pub orphans_deleted: usize,
177
178    /// Total bytes freed by the deletion.
179    pub freed_bytes: u64,
180
181    /// Errors that occurred during pruning.
182    ///
183    /// Pruning continues despite individual errors, so this list may contain
184    /// multiple entries.
185    pub errors: Vec<RecordPruneError>,
186}
187
188impl PruneResult {
189    /// Returns a display wrapper for the prune result.
190    pub fn display<'a>(&'a self, styles: &'a Styles) -> DisplayPruneResult<'a> {
191        DisplayPruneResult {
192            result: self,
193            styles,
194        }
195    }
196}
197
198/// The result of computing a prune plan (dry-run mode).
199///
200/// Contains information about which runs would be deleted if the prune
201/// operation were executed.
202#[derive(Clone, Debug)]
203pub struct PrunePlan {
204    /// Runs that would be deleted.
205    runs: Vec<RecordedRunInfo>,
206}
207
208impl PrunePlan {
209    /// Creates a new prune plan from a list of runs to delete.
210    ///
211    /// The runs are sorted by start time (oldest first).
212    pub(crate) fn new(mut runs: Vec<RecordedRunInfo>) -> Self {
213        runs.sort_by(|a, b| a.started_at.cmp(&b.started_at));
214        Self { runs }
215    }
216
217    pub(super) fn compute(runs: &[RecordedRunInfo], policy: &RecordRetentionPolicy) -> Self {
218        let now = Utc::now();
219        let to_delete: HashSet<_> = policy
220            .compute_runs_to_delete(runs, now)
221            .into_iter()
222            .collect();
223
224        let runs_to_delete: Vec<_> = runs
225            .iter()
226            .filter(|r| to_delete.contains(&r.run_id))
227            .cloned()
228            .collect();
229
230        Self::new(runs_to_delete)
231    }
232
233    /// Returns the runs that would be deleted.
234    pub fn runs(&self) -> &[RecordedRunInfo] {
235        &self.runs
236    }
237
238    /// Returns the number of runs that would be deleted.
239    pub fn run_count(&self) -> usize {
240        self.runs.len()
241    }
242
243    /// Returns the total size in bytes of runs that would be deleted.
244    pub fn total_bytes(&self) -> u64 {
245        self.runs.iter().map(|r| r.sizes.total_compressed()).sum()
246    }
247
248    /// Returns a display wrapper for the prune plan.
249    ///
250    /// The `run_id_index` is used for computing shortest unique prefixes,
251    /// which are highlighted differently in the output (similar to jj).
252    ///
253    /// The `redactor` parameter, if provided, redacts timestamps, durations,
254    /// and sizes for snapshot testing while preserving column alignment.
255    pub fn display<'a>(
256        &'a self,
257        run_id_index: &'a RunIdIndex,
258        styles: &'a Styles,
259        redactor: &'a Redactor,
260    ) -> DisplayPrunePlan<'a> {
261        DisplayPrunePlan {
262            plan: self,
263            run_id_index,
264            styles,
265            redactor,
266        }
267    }
268}
269
270/// Deletes run directories and updates the run list.
271///
272/// This function:
273/// 1. Deletes each run directory from disk
274/// 2. Removes deleted runs from the provided list
275/// 3. Returns statistics about the operation
276///
277/// Deletion continues even if individual runs fail to delete. Errors are
278/// collected in the returned `PruneResult`.
279pub(crate) fn delete_runs(
280    runs_dir: StoreRunsDir<'_>,
281    runs: &mut Vec<RecordedRunInfo>,
282    to_delete: &HashSet<ReportUuid>,
283) -> PruneResult {
284    let mut result = PruneResult::default();
285
286    for run_id in to_delete {
287        let run_dir = runs_dir.run_dir(*run_id);
288
289        let size_bytes = runs
290            .iter()
291            .find(|r| &r.run_id == run_id)
292            .map(|r| r.sizes.total_compressed())
293            .unwrap_or(0);
294
295        match std::fs::remove_dir_all(&run_dir) {
296            Ok(()) => {
297                result.deleted_count += 1;
298                result.freed_bytes += size_bytes;
299            }
300            Err(error) => {
301                // Don't treat "not found" as an error - the directory may have
302                // already been deleted or never created.
303                if error.kind() != std::io::ErrorKind::NotFound {
304                    result.errors.push(RecordPruneError::DeleteRun {
305                        run_id: *run_id,
306                        path: run_dir,
307                        error,
308                    });
309                } else {
310                    // Still count it as deleted since it's no longer present.
311                    result.deleted_count += 1;
312                    result.freed_bytes += size_bytes;
313                }
314            }
315        }
316    }
317
318    runs.retain(|run| !to_delete.contains(&run.run_id));
319
320    result
321}
322
323/// Deletes orphaned run directories that are not tracked in runs.json.zst.
324///
325/// An orphaned directory is one that exists on disk but whose UUID is not
326/// present in the `known_runs` set. This can happen if a process crashes
327/// after creating a run directory but before the run completes.
328pub(crate) fn delete_orphaned_dirs(
329    runs_dir: StoreRunsDir<'_>,
330    known_runs: &HashSet<ReportUuid>,
331    result: &mut PruneResult,
332) {
333    let runs_path = runs_dir.as_path();
334    let entries = match runs_path.read_dir_utf8() {
335        Ok(entries) => entries,
336        Err(error) => {
337            // If we can't read the directory, record the error but don't fail.
338            if error.kind() != std::io::ErrorKind::NotFound {
339                result.errors.push(RecordPruneError::ReadRunsDir {
340                    path: runs_path.to_owned(),
341                    error,
342                });
343            }
344            return;
345        }
346    };
347
348    for entry in entries {
349        let entry = match entry {
350            Ok(entry) => entry,
351            Err(error) => {
352                result.errors.push(RecordPruneError::ReadDirEntry {
353                    dir: runs_path.to_owned(),
354                    error,
355                });
356                continue;
357            }
358        };
359
360        let entry_path = entry.path();
361        let file_type = match entry.file_type() {
362            Ok(ft) => ft,
363            Err(error) => {
364                result.errors.push(RecordPruneError::ReadFileType {
365                    path: entry_path.to_owned(),
366                    error,
367                });
368                continue;
369            }
370        };
371        if !file_type.is_dir() {
372            continue;
373        }
374
375        let dir_name = entry.file_name();
376        let run_id = match dir_name.parse::<ReportUuid>() {
377            Ok(id) => id,
378            // Not a UUID directory, skip without error.
379            Err(_) => continue,
380        };
381
382        if known_runs.contains(&run_id) {
383            continue;
384        }
385
386        let path = runs_dir.run_dir(run_id);
387        match std::fs::remove_dir_all(&path) {
388            Ok(()) => {
389                result.orphans_deleted += 1;
390            }
391            Err(error) => {
392                if error.kind() != std::io::ErrorKind::NotFound {
393                    result
394                        .errors
395                        .push(RecordPruneError::DeleteOrphan { path, error });
396                }
397            }
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::record::{
406        CompletedRunStats, ComponentSizes, RecordedRunStatus, RecordedSizes,
407        format::RECORD_FORMAT_VERSION,
408    };
409    use chrono::{FixedOffset, TimeZone};
410    use semver::Version;
411    use std::collections::BTreeMap;
412
413    fn make_run(
414        run_id: ReportUuid,
415        started_at: DateTime<FixedOffset>,
416        total_compressed_size: u64,
417        status: RecordedRunStatus,
418    ) -> RecordedRunInfo {
419        // For simplicity in tests, put all size in the store component.
420        RecordedRunInfo {
421            run_id,
422            store_format_version: RECORD_FORMAT_VERSION,
423            nextest_version: Version::new(0, 1, 0),
424            started_at,
425            last_written_at: started_at,
426            duration_secs: Some(1.0),
427            cli_args: Vec::new(),
428            build_scope_args: Vec::new(),
429            env_vars: BTreeMap::new(),
430            parent_run_id: None,
431            sizes: RecordedSizes {
432                log: ComponentSizes::default(),
433                store: ComponentSizes {
434                    compressed: total_compressed_size,
435                    // Use a fixed ratio for uncompressed size in tests.
436                    uncompressed: total_compressed_size * 3,
437                    entries: 0,
438                },
439            },
440            status,
441        }
442    }
443
444    /// Creates a simple completed status for tests.
445    fn completed_status() -> RecordedRunStatus {
446        RecordedRunStatus::Completed(CompletedRunStats {
447            initial_run_count: 10,
448            passed: 10,
449            failed: 0,
450            exit_code: 0,
451        })
452    }
453
454    /// Creates an incomplete status for tests.
455    fn incomplete_status() -> RecordedRunStatus {
456        RecordedRunStatus::Incomplete
457    }
458
459    // Test time helpers. The base time is arbitrary; what matters is the
460    // relative offsets between run start times and "now".
461    const BASE_YEAR: i32 = 2024;
462
463    /// Creates a run start time at base + offset seconds.
464    fn run_start_time(secs_offset: i64) -> DateTime<FixedOffset> {
465        FixedOffset::east_opt(0)
466            .unwrap()
467            .with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0)
468            .unwrap()
469            + chrono::Duration::seconds(secs_offset)
470    }
471
472    /// Returns the simulated "current time" for tests: 60 days after the base.
473    fn now_time() -> DateTime<Utc> {
474        Utc.with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::days(60)
475    }
476
477    #[test]
478    fn test_no_limits_keeps_all() {
479        let policy = RecordRetentionPolicy::default();
480        let runs = vec![
481            make_run(
482                ReportUuid::new_v4(),
483                run_start_time(0),
484                1000,
485                completed_status(),
486            ),
487            make_run(
488                ReportUuid::new_v4(),
489                run_start_time(100),
490                2000,
491                completed_status(),
492            ),
493        ];
494
495        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
496        assert!(to_delete.is_empty());
497    }
498
499    #[test]
500    fn test_incomplete_runs_not_automatically_deleted() {
501        let policy = RecordRetentionPolicy {
502            max_count: Some(2),
503            ..Default::default()
504        };
505
506        let oldest_id = ReportUuid::new_v4();
507        let runs = vec![
508            make_run(oldest_id, run_start_time(0), 1000, completed_status()),
509            make_run(
510                ReportUuid::new_v4(),
511                run_start_time(100),
512                2000,
513                incomplete_status(),
514            ), // incomplete
515            make_run(
516                ReportUuid::new_v4(),
517                run_start_time(200),
518                1000,
519                completed_status(),
520            ),
521        ];
522
523        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
524        assert_eq!(to_delete.len(), 1);
525        assert_eq!(to_delete[0], oldest_id);
526    }
527
528    #[test]
529    fn test_count_limit() {
530        let policy = RecordRetentionPolicy {
531            max_count: Some(2),
532            ..Default::default()
533        };
534
535        let oldest_id = ReportUuid::new_v4();
536        let runs = vec![
537            make_run(oldest_id, run_start_time(0), 1000, completed_status()),
538            make_run(
539                ReportUuid::new_v4(),
540                run_start_time(100),
541                1000,
542                completed_status(),
543            ),
544            make_run(
545                ReportUuid::new_v4(),
546                run_start_time(200),
547                1000,
548                completed_status(),
549            ),
550        ];
551
552        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
553        assert_eq!(to_delete.len(), 1);
554        assert_eq!(to_delete[0], oldest_id);
555    }
556
557    #[test]
558    fn test_size_limit() {
559        let policy = RecordRetentionPolicy {
560            max_total_size: Some(ByteSize::b(2500)),
561            ..Default::default()
562        };
563
564        let oldest_id = ReportUuid::new_v4();
565        let runs = vec![
566            make_run(oldest_id, run_start_time(0), 1000, completed_status()),
567            make_run(
568                ReportUuid::new_v4(),
569                run_start_time(100),
570                1000,
571                completed_status(),
572            ),
573            make_run(
574                ReportUuid::new_v4(),
575                run_start_time(200),
576                1000,
577                completed_status(),
578            ),
579        ];
580
581        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
582        assert_eq!(to_delete.len(), 1);
583        assert_eq!(to_delete[0], oldest_id);
584    }
585
586    #[test]
587    fn test_age_limit() {
588        let policy = RecordRetentionPolicy {
589            max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), // 30 days
590            ..Default::default()
591        };
592
593        let old_id = ReportUuid::new_v4();
594        let runs = vec![
595            make_run(old_id, run_start_time(0), 1000, completed_status()), // 60 days old.
596            make_run(
597                ReportUuid::new_v4(),
598                run_start_time(45 * 24 * 60 * 60), // 15 days old.
599                1000,
600                completed_status(),
601            ),
602        ];
603
604        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
605        assert_eq!(to_delete.len(), 1);
606        assert_eq!(to_delete[0], old_id);
607    }
608
609    #[test]
610    fn test_combined_limits() {
611        let policy = RecordRetentionPolicy {
612            max_count: Some(2),
613            max_total_size: Some(ByteSize::b(2500)),
614            max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)),
615        };
616
617        let old_id = ReportUuid::new_v4();
618        let runs = vec![
619            make_run(old_id, run_start_time(0), 1000, completed_status()), // 60 days old.
620            make_run(
621                ReportUuid::new_v4(),
622                run_start_time(45 * 24 * 60 * 60), // 15 days old.
623                1000,
624                completed_status(),
625            ),
626            make_run(
627                ReportUuid::new_v4(),
628                run_start_time(50 * 24 * 60 * 60), // 10 days old.
629                1000,
630                completed_status(),
631            ),
632        ];
633
634        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
635        assert_eq!(to_delete.len(), 1);
636        assert_eq!(to_delete[0], old_id);
637    }
638
639    #[test]
640    fn test_from_record_config() {
641        let config = RecordConfig {
642            enabled: true,
643            max_records: 50,
644            max_total_size: ByteSize::gb(2),
645            max_age: Duration::from_secs(7 * 24 * 60 * 60),
646            max_output_size: ByteSize::mb(10),
647        };
648
649        let policy = RecordRetentionPolicy::from(&config);
650
651        assert_eq!(policy.max_count, Some(50));
652        assert_eq!(policy.max_total_size, Some(ByteSize::gb(2)));
653        assert_eq!(policy.max_age, Some(Duration::from_secs(7 * 24 * 60 * 60)));
654    }
655
656    #[test]
657    fn test_all_runs_deleted_by_age() {
658        let policy = RecordRetentionPolicy {
659            max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), // 7 days
660            ..Default::default()
661        };
662
663        let id1 = ReportUuid::new_v4();
664        let id2 = ReportUuid::new_v4();
665        let id3 = ReportUuid::new_v4();
666        let runs = vec![
667            make_run(id1, run_start_time(0), 1000, completed_status()), // 60 days old
668            make_run(id2, run_start_time(100), 1000, completed_status()), // 60 days old
669            make_run(id3, run_start_time(200), 1000, completed_status()), // 60 days old
670        ];
671
672        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
673        assert_eq!(to_delete.len(), 3, "all runs should be deleted");
674        assert!(to_delete.contains(&id1));
675        assert!(to_delete.contains(&id2));
676        assert!(to_delete.contains(&id3));
677    }
678
679    #[test]
680    fn test_all_runs_deleted_by_count_zero() {
681        let policy = RecordRetentionPolicy {
682            max_count: Some(0),
683            ..Default::default()
684        };
685
686        let id1 = ReportUuid::new_v4();
687        let id2 = ReportUuid::new_v4();
688        let runs = vec![
689            make_run(id1, run_start_time(0), 1000, completed_status()),
690            make_run(id2, run_start_time(100), 1000, completed_status()),
691        ];
692
693        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
694        assert_eq!(
695            to_delete.len(),
696            2,
697            "all runs should be deleted with max_count=0"
698        );
699        assert!(to_delete.contains(&id1));
700        assert!(to_delete.contains(&id2));
701    }
702
703    #[test]
704    fn test_all_runs_deleted_by_size() {
705        let policy = RecordRetentionPolicy {
706            max_total_size: Some(ByteSize::b(0)),
707            ..Default::default()
708        };
709
710        let id1 = ReportUuid::new_v4();
711        let id2 = ReportUuid::new_v4();
712        let runs = vec![
713            make_run(id1, run_start_time(0), 1000, completed_status()),
714            make_run(id2, run_start_time(100), 1000, completed_status()),
715        ];
716
717        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
718        assert_eq!(
719            to_delete.len(),
720            2,
721            "all runs should be deleted with max_total_size=0"
722        );
723        assert!(to_delete.contains(&id1));
724        assert!(to_delete.contains(&id2));
725    }
726
727    #[test]
728    fn test_empty_runs_list() {
729        let policy = RecordRetentionPolicy {
730            max_count: Some(5),
731            max_total_size: Some(ByteSize::mb(100)),
732            max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)),
733        };
734
735        let runs: Vec<RecordedRunInfo> = vec![];
736        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
737        assert!(
738            to_delete.is_empty(),
739            "empty input should return empty output"
740        );
741    }
742
743    #[test]
744    fn test_clock_skew_negative_age_saturates_to_zero() {
745        // If the system clock moved backward between recording and pruning, the
746        // run's start time could be in the "future" relative to now. This should
747        // be treated as age 0 (just created), not as a negative age.
748        let policy = RecordRetentionPolicy {
749            max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), // 7 days
750            ..Default::default()
751        };
752
753        // Create a run that started "in the future" (1 day after now_time).
754        let future_start = run_start_time(61 * 24 * 60 * 60); // 61 days after base = 1 day after now_time
755        let future_id = ReportUuid::new_v4();
756
757        // Also include a legitimately old run for comparison.
758        let old_id = ReportUuid::new_v4();
759        let runs = vec![
760            make_run(old_id, run_start_time(0), 1000, completed_status()), // 60 days old, should be deleted
761            make_run(future_id, future_start, 1000, completed_status()), // "future" run, should be kept
762        ];
763
764        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
765
766        // The old run should be deleted (exceeds 7 day limit).
767        // The "future" run should be kept (age saturates to 0).
768        assert_eq!(to_delete.len(), 1, "only old run should be deleted");
769        assert_eq!(to_delete[0], old_id);
770        assert!(
771            !to_delete.contains(&future_id),
772            "future run should be kept due to clock skew handling"
773        );
774    }
775
776    #[test]
777    fn test_limits_exceeded_by_factor() {
778        // Test count limit.
779        let count_policy = RecordRetentionPolicy {
780            max_count: Some(10),
781            ..Default::default()
782        };
783
784        // 15 runs is exactly 1.5x the limit of 10.
785        let runs_15: Vec<_> = (0..15)
786            .map(|i| {
787                make_run(
788                    ReportUuid::new_v4(),
789                    run_start_time(i),
790                    100,
791                    completed_status(),
792                )
793            })
794            .collect();
795
796        // At exactly 1.5x, should not be exceeded (we use > not >=).
797        assert!(
798            !count_policy.limits_exceeded_by_factor(&runs_15, 1.5),
799            "15 runs should not exceed 1.5x limit of 10"
800        );
801
802        // 16 runs exceeds 1.5x the limit of 10.
803        let mut runs_16 = runs_15.clone();
804        runs_16.push(make_run(
805            ReportUuid::new_v4(),
806            run_start_time(16),
807            100,
808            completed_status(),
809        ));
810        assert!(
811            count_policy.limits_exceeded_by_factor(&runs_16, 1.5),
812            "16 runs should exceed 1.5x limit of 10"
813        );
814
815        // Test size limit.
816        let size_policy = RecordRetentionPolicy {
817            max_total_size: Some(ByteSize::b(1000)),
818            ..Default::default()
819        };
820
821        // 1500 bytes is exactly 1.5x the limit of 1000.
822        let runs_1500 = vec![make_run(
823            ReportUuid::new_v4(),
824            run_start_time(0),
825            1500,
826            completed_status(),
827        )];
828        assert!(
829            !size_policy.limits_exceeded_by_factor(&runs_1500, 1.5),
830            "1500 bytes should not exceed 1.5x limit of 1000"
831        );
832
833        // 1501 bytes exceeds 1.5x the limit of 1000.
834        let runs_1501 = vec![make_run(
835            ReportUuid::new_v4(),
836            run_start_time(0),
837            1501,
838            completed_status(),
839        )];
840        assert!(
841            size_policy.limits_exceeded_by_factor(&runs_1501, 1.5),
842            "1501 bytes should exceed 1.5x limit of 1000"
843        );
844
845        // Test no limits set.
846        let no_limits_policy = RecordRetentionPolicy::default();
847        let many_runs: Vec<_> = (0..100)
848            .map(|i| {
849                make_run(
850                    ReportUuid::new_v4(),
851                    run_start_time(i),
852                    1_000_000,
853                    completed_status(),
854                )
855            })
856            .collect();
857        assert!(
858            !no_limits_policy.limits_exceeded_by_factor(&many_runs, 1.5),
859            "no limits set should never be exceeded"
860        );
861
862        // Test empty runs.
863        let runs_empty: Vec<RecordedRunInfo> = vec![];
864        assert!(
865            !count_policy.limits_exceeded_by_factor(&runs_empty, 1.5),
866            "empty runs should not exceed limits"
867        );
868    }
869}