Skip to main content

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_key(|run| std::cmp::Reverse(run.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_key(|run| run.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::STORE_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: STORE_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            env_vars: BTreeMap::new(),
429            parent_run_id: None,
430            sizes: RecordedSizes {
431                log: ComponentSizes::default(),
432                store: ComponentSizes {
433                    compressed: total_compressed_size,
434                    // Use a fixed ratio for uncompressed size in tests.
435                    uncompressed: total_compressed_size * 3,
436                    entries: 0,
437                },
438            },
439            status,
440        }
441    }
442
443    /// Creates a simple completed status for tests.
444    fn completed_status() -> RecordedRunStatus {
445        RecordedRunStatus::Completed(CompletedRunStats {
446            initial_run_count: 10,
447            passed: 10,
448            failed: 0,
449            exit_code: 0,
450        })
451    }
452
453    /// Creates an incomplete status for tests.
454    fn incomplete_status() -> RecordedRunStatus {
455        RecordedRunStatus::Incomplete
456    }
457
458    // Test time helpers. The base time is arbitrary; what matters is the
459    // relative offsets between run start times and "now".
460    const BASE_YEAR: i32 = 2024;
461
462    /// Creates a run start time at base + offset seconds.
463    fn run_start_time(secs_offset: i64) -> DateTime<FixedOffset> {
464        FixedOffset::east_opt(0)
465            .unwrap()
466            .with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0)
467            .unwrap()
468            + chrono::Duration::seconds(secs_offset)
469    }
470
471    /// Returns the simulated "current time" for tests: 60 days after the base.
472    fn now_time() -> DateTime<Utc> {
473        Utc.with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::days(60)
474    }
475
476    #[test]
477    fn test_no_limits_keeps_all() {
478        let policy = RecordRetentionPolicy::default();
479        let runs = vec![
480            make_run(
481                ReportUuid::new_v4(),
482                run_start_time(0),
483                1000,
484                completed_status(),
485            ),
486            make_run(
487                ReportUuid::new_v4(),
488                run_start_time(100),
489                2000,
490                completed_status(),
491            ),
492        ];
493
494        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
495        assert!(to_delete.is_empty());
496    }
497
498    #[test]
499    fn test_incomplete_runs_not_automatically_deleted() {
500        let policy = RecordRetentionPolicy {
501            max_count: Some(2),
502            ..Default::default()
503        };
504
505        let oldest_id = ReportUuid::new_v4();
506        let runs = vec![
507            make_run(oldest_id, run_start_time(0), 1000, completed_status()),
508            make_run(
509                ReportUuid::new_v4(),
510                run_start_time(100),
511                2000,
512                incomplete_status(),
513            ), // incomplete
514            make_run(
515                ReportUuid::new_v4(),
516                run_start_time(200),
517                1000,
518                completed_status(),
519            ),
520        ];
521
522        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
523        assert_eq!(to_delete.len(), 1);
524        assert_eq!(to_delete[0], oldest_id);
525    }
526
527    #[test]
528    fn test_count_limit() {
529        let policy = RecordRetentionPolicy {
530            max_count: Some(2),
531            ..Default::default()
532        };
533
534        let oldest_id = ReportUuid::new_v4();
535        let runs = vec![
536            make_run(oldest_id, run_start_time(0), 1000, completed_status()),
537            make_run(
538                ReportUuid::new_v4(),
539                run_start_time(100),
540                1000,
541                completed_status(),
542            ),
543            make_run(
544                ReportUuid::new_v4(),
545                run_start_time(200),
546                1000,
547                completed_status(),
548            ),
549        ];
550
551        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
552        assert_eq!(to_delete.len(), 1);
553        assert_eq!(to_delete[0], oldest_id);
554    }
555
556    #[test]
557    fn test_size_limit() {
558        let policy = RecordRetentionPolicy {
559            max_total_size: Some(ByteSize::b(2500)),
560            ..Default::default()
561        };
562
563        let oldest_id = ReportUuid::new_v4();
564        let runs = vec![
565            make_run(oldest_id, run_start_time(0), 1000, completed_status()),
566            make_run(
567                ReportUuid::new_v4(),
568                run_start_time(100),
569                1000,
570                completed_status(),
571            ),
572            make_run(
573                ReportUuid::new_v4(),
574                run_start_time(200),
575                1000,
576                completed_status(),
577            ),
578        ];
579
580        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
581        assert_eq!(to_delete.len(), 1);
582        assert_eq!(to_delete[0], oldest_id);
583    }
584
585    #[test]
586    fn test_age_limit() {
587        let policy = RecordRetentionPolicy {
588            max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), // 30 days
589            ..Default::default()
590        };
591
592        let old_id = ReportUuid::new_v4();
593        let runs = vec![
594            make_run(old_id, run_start_time(0), 1000, completed_status()), // 60 days old.
595            make_run(
596                ReportUuid::new_v4(),
597                run_start_time(45 * 24 * 60 * 60), // 15 days old.
598                1000,
599                completed_status(),
600            ),
601        ];
602
603        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
604        assert_eq!(to_delete.len(), 1);
605        assert_eq!(to_delete[0], old_id);
606    }
607
608    #[test]
609    fn test_combined_limits() {
610        let policy = RecordRetentionPolicy {
611            max_count: Some(2),
612            max_total_size: Some(ByteSize::b(2500)),
613            max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)),
614        };
615
616        let old_id = ReportUuid::new_v4();
617        let runs = vec![
618            make_run(old_id, run_start_time(0), 1000, completed_status()), // 60 days old.
619            make_run(
620                ReportUuid::new_v4(),
621                run_start_time(45 * 24 * 60 * 60), // 15 days old.
622                1000,
623                completed_status(),
624            ),
625            make_run(
626                ReportUuid::new_v4(),
627                run_start_time(50 * 24 * 60 * 60), // 10 days old.
628                1000,
629                completed_status(),
630            ),
631        ];
632
633        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
634        assert_eq!(to_delete.len(), 1);
635        assert_eq!(to_delete[0], old_id);
636    }
637
638    #[test]
639    fn test_from_record_config() {
640        let config = RecordConfig {
641            enabled: true,
642            max_records: 50,
643            max_total_size: ByteSize::gb(2),
644            max_age: Duration::from_secs(7 * 24 * 60 * 60),
645            max_output_size: ByteSize::mb(10),
646        };
647
648        let policy = RecordRetentionPolicy::from(&config);
649
650        assert_eq!(policy.max_count, Some(50));
651        assert_eq!(policy.max_total_size, Some(ByteSize::gb(2)));
652        assert_eq!(policy.max_age, Some(Duration::from_secs(7 * 24 * 60 * 60)));
653    }
654
655    #[test]
656    fn test_all_runs_deleted_by_age() {
657        let policy = RecordRetentionPolicy {
658            max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), // 7 days
659            ..Default::default()
660        };
661
662        let id1 = ReportUuid::new_v4();
663        let id2 = ReportUuid::new_v4();
664        let id3 = ReportUuid::new_v4();
665        let runs = vec![
666            make_run(id1, run_start_time(0), 1000, completed_status()), // 60 days old
667            make_run(id2, run_start_time(100), 1000, completed_status()), // 60 days old
668            make_run(id3, run_start_time(200), 1000, completed_status()), // 60 days old
669        ];
670
671        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
672        assert_eq!(to_delete.len(), 3, "all runs should be deleted");
673        assert!(to_delete.contains(&id1));
674        assert!(to_delete.contains(&id2));
675        assert!(to_delete.contains(&id3));
676    }
677
678    #[test]
679    fn test_all_runs_deleted_by_count_zero() {
680        let policy = RecordRetentionPolicy {
681            max_count: Some(0),
682            ..Default::default()
683        };
684
685        let id1 = ReportUuid::new_v4();
686        let id2 = ReportUuid::new_v4();
687        let runs = vec![
688            make_run(id1, run_start_time(0), 1000, completed_status()),
689            make_run(id2, run_start_time(100), 1000, completed_status()),
690        ];
691
692        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
693        assert_eq!(
694            to_delete.len(),
695            2,
696            "all runs should be deleted with max_count=0"
697        );
698        assert!(to_delete.contains(&id1));
699        assert!(to_delete.contains(&id2));
700    }
701
702    #[test]
703    fn test_all_runs_deleted_by_size() {
704        let policy = RecordRetentionPolicy {
705            max_total_size: Some(ByteSize::b(0)),
706            ..Default::default()
707        };
708
709        let id1 = ReportUuid::new_v4();
710        let id2 = ReportUuid::new_v4();
711        let runs = vec![
712            make_run(id1, run_start_time(0), 1000, completed_status()),
713            make_run(id2, run_start_time(100), 1000, completed_status()),
714        ];
715
716        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
717        assert_eq!(
718            to_delete.len(),
719            2,
720            "all runs should be deleted with max_total_size=0"
721        );
722        assert!(to_delete.contains(&id1));
723        assert!(to_delete.contains(&id2));
724    }
725
726    #[test]
727    fn test_empty_runs_list() {
728        let policy = RecordRetentionPolicy {
729            max_count: Some(5),
730            max_total_size: Some(ByteSize::mb(100)),
731            max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)),
732        };
733
734        let runs: Vec<RecordedRunInfo> = vec![];
735        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
736        assert!(
737            to_delete.is_empty(),
738            "empty input should return empty output"
739        );
740    }
741
742    #[test]
743    fn test_clock_skew_negative_age_saturates_to_zero() {
744        // If the system clock moved backward between recording and pruning, the
745        // run's start time could be in the "future" relative to now. This should
746        // be treated as age 0 (just created), not as a negative age.
747        let policy = RecordRetentionPolicy {
748            max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), // 7 days
749            ..Default::default()
750        };
751
752        // Create a run that started "in the future" (1 day after now_time).
753        let future_start = run_start_time(61 * 24 * 60 * 60); // 61 days after base = 1 day after now_time
754        let future_id = ReportUuid::new_v4();
755
756        // Also include a legitimately old run for comparison.
757        let old_id = ReportUuid::new_v4();
758        let runs = vec![
759            make_run(old_id, run_start_time(0), 1000, completed_status()), // 60 days old, should be deleted
760            make_run(future_id, future_start, 1000, completed_status()), // "future" run, should be kept
761        ];
762
763        let to_delete = policy.compute_runs_to_delete(&runs, now_time());
764
765        // The old run should be deleted (exceeds 7 day limit).
766        // The "future" run should be kept (age saturates to 0).
767        assert_eq!(to_delete.len(), 1, "only old run should be deleted");
768        assert_eq!(to_delete[0], old_id);
769        assert!(
770            !to_delete.contains(&future_id),
771            "future run should be kept due to clock skew handling"
772        );
773    }
774
775    #[test]
776    fn test_limits_exceeded_by_factor() {
777        // Test count limit.
778        let count_policy = RecordRetentionPolicy {
779            max_count: Some(10),
780            ..Default::default()
781        };
782
783        // 15 runs is exactly 1.5x the limit of 10.
784        let runs_15: Vec<_> = (0..15)
785            .map(|i| {
786                make_run(
787                    ReportUuid::new_v4(),
788                    run_start_time(i),
789                    100,
790                    completed_status(),
791                )
792            })
793            .collect();
794
795        // At exactly 1.5x, should not be exceeded (we use > not >=).
796        assert!(
797            !count_policy.limits_exceeded_by_factor(&runs_15, 1.5),
798            "15 runs should not exceed 1.5x limit of 10"
799        );
800
801        // 16 runs exceeds 1.5x the limit of 10.
802        let mut runs_16 = runs_15.clone();
803        runs_16.push(make_run(
804            ReportUuid::new_v4(),
805            run_start_time(16),
806            100,
807            completed_status(),
808        ));
809        assert!(
810            count_policy.limits_exceeded_by_factor(&runs_16, 1.5),
811            "16 runs should exceed 1.5x limit of 10"
812        );
813
814        // Test size limit.
815        let size_policy = RecordRetentionPolicy {
816            max_total_size: Some(ByteSize::b(1000)),
817            ..Default::default()
818        };
819
820        // 1500 bytes is exactly 1.5x the limit of 1000.
821        let runs_1500 = vec![make_run(
822            ReportUuid::new_v4(),
823            run_start_time(0),
824            1500,
825            completed_status(),
826        )];
827        assert!(
828            !size_policy.limits_exceeded_by_factor(&runs_1500, 1.5),
829            "1500 bytes should not exceed 1.5x limit of 1000"
830        );
831
832        // 1501 bytes exceeds 1.5x the limit of 1000.
833        let runs_1501 = vec![make_run(
834            ReportUuid::new_v4(),
835            run_start_time(0),
836            1501,
837            completed_status(),
838        )];
839        assert!(
840            size_policy.limits_exceeded_by_factor(&runs_1501, 1.5),
841            "1501 bytes should exceed 1.5x limit of 1000"
842        );
843
844        // Test no limits set.
845        let no_limits_policy = RecordRetentionPolicy::default();
846        let many_runs: Vec<_> = (0..100)
847            .map(|i| {
848                make_run(
849                    ReportUuid::new_v4(),
850                    run_start_time(i),
851                    1_000_000,
852                    completed_status(),
853                )
854            })
855            .collect();
856        assert!(
857            !no_limits_policy.limits_exceeded_by_factor(&many_runs, 1.5),
858            "no limits set should never be exceeded"
859        );
860
861        // Test empty runs.
862        let runs_empty: Vec<RecordedRunInfo> = vec![];
863        assert!(
864            !count_policy.limits_exceeded_by_factor(&runs_empty, 1.5),
865            "empty runs should not exceed limits"
866        );
867    }
868}