Skip to main content

cargo/resolver/
version_prefs.rs

1//! This module implements support for preferring some versions of a package
2//! over other versions.
3
4use crate::util::data_structures::{HashMap, HashSet};
5use std::cmp::Ordering;
6use std::time::Duration;
7
8use cargo_util_schemas::core::PartialVersion;
9
10use crate::context::CargoResolverConfig;
11use crate::context::GlobalRegistryConfig;
12use crate::context::IncompatiblePublishAge;
13use crate::context::RegistryConfig;
14use crate::sources::CRATES_IO_REGISTRY;
15use crate::util::CargoResult;
16use crate::util::GlobalContext;
17use crate::util::interning::InternedString;
18use crate::util::time_span::parse_time_span;
19use crate::workspace::Dependency;
20use crate::workspace::PackageId;
21use crate::workspace::SourceId;
22use crate::workspace::Summary;
23
24/// A collection of preferences for particular package versions.
25///
26/// This is built up with [`Self::prefer_package_id`] and [`Self::prefer_dependency`], then used to sort the set of
27/// summaries for a package during resolution via [`Self::sort_summaries`].
28///
29/// As written, a version is either "preferred" or "not preferred".  Later extensions may
30/// introduce more granular preferences.
31#[derive(Default)]
32pub struct VersionPreferences {
33    try_to_use: HashSet<PackageId>,
34    prefer_patch_deps: HashMap<InternedString, HashSet<Dependency>>,
35    version_ordering: VersionOrdering,
36    rust_versions: Vec<PartialVersion>,
37    publish_time: Option<jiff::Timestamp>,
38    publish_age: Option<PublishAgePolicy>,
39}
40
41#[derive(Copy, Clone, Default, PartialEq, Eq, Hash, Debug)]
42pub enum VersionOrdering {
43    #[default]
44    MaximumVersionsFirst,
45    MinimumVersionsFirst,
46}
47
48impl VersionPreferences {
49    /// Indicate that the given package (specified as a [`PackageId`]) should be preferred.
50    pub fn prefer_package_id(&mut self, pkg_id: PackageId) {
51        self.try_to_use.insert(pkg_id);
52    }
53
54    /// Indicate that the given package (specified as a [`Dependency`])  should be preferred.
55    pub fn prefer_dependency(&mut self, dep: Dependency) {
56        self.prefer_patch_deps
57            .entry(dep.package_name())
58            .or_insert_with(HashSet::default)
59            .insert(dep);
60    }
61
62    pub fn version_ordering(&mut self, ordering: VersionOrdering) {
63        self.version_ordering = ordering;
64    }
65
66    pub fn rust_versions(&mut self, vers: Vec<PartialVersion>) {
67        self.rust_versions = vers;
68    }
69
70    pub fn publish_time(&mut self, publish_time: jiff::Timestamp) {
71        self.publish_time = Some(publish_time);
72    }
73
74    pub fn publish_age(&mut self, policy: PublishAgePolicy) {
75        self.publish_age = Some(policy);
76    }
77
78    /// Returns the version's publish-age if it is too new for the configured
79    /// `min-publish-age`, otherwise `None`.
80    pub fn too_new(&self, summary: &Summary) -> Option<PublishAgeViolation> {
81        self.publish_age.as_ref()?.too_new(summary)
82    }
83
84    /// Whether the given package is preferred.
85    pub fn should_prefer(&self, pkg_id: &PackageId) -> bool {
86        self.try_to_use.contains(pkg_id)
87            || self
88                .prefer_patch_deps
89                .get(&pkg_id.name())
90                .map(|deps| deps.iter().any(|d| d.matches_id(*pkg_id)))
91                .unwrap_or(false)
92    }
93
94    /// Sort (and filter) the given vector of summaries in-place
95    ///
96    /// Note: all summaries presumed to be for the same package.
97    ///
98    /// Sort order:
99    /// 1. Preferred packages
100    /// 2. Most compatible [`VersionPreferences::rust_versions`]
101    /// 3. `first_version`, falling back to [`VersionPreferences::version_ordering`] when `None`
102    ///
103    /// Filtering:
104    /// - `publish_time`
105    /// - `first_version`
106    pub fn sort_summaries(
107        &self,
108        summaries: &mut Vec<Summary>,
109        first_version: Option<VersionOrdering>,
110    ) {
111        if let Some(max_publish_time) = self.publish_time {
112            summaries.retain(|s| {
113                if let Some(summary_publish_time) = s.pubtime() {
114                    summary_publish_time <= max_publish_time
115                } else {
116                    true
117                }
118            });
119        }
120        summaries.sort_unstable_by(|a, b| {
121            let prefer_a = self.should_prefer(&a.package_id());
122            let prefer_b = self.should_prefer(&b.package_id());
123            let previous_cmp = prefer_a.cmp(&prefer_b).reverse();
124            if previous_cmp != Ordering::Equal {
125                return previous_cmp;
126            }
127
128            if !self.rust_versions.is_empty() {
129                let a_compat_count = self.msrv_compat_count(a);
130                let b_compat_count = self.msrv_compat_count(b);
131                if b_compat_count != a_compat_count {
132                    return b_compat_count.cmp(&a_compat_count);
133                }
134            }
135
136            let cmp = a.version().cmp(b.version());
137            match first_version.unwrap_or(self.version_ordering) {
138                VersionOrdering::MaximumVersionsFirst => cmp.reverse(),
139                VersionOrdering::MinimumVersionsFirst => cmp,
140            }
141        });
142        if first_version.is_some() && !summaries.is_empty() {
143            let _ = summaries.split_off(1);
144        }
145    }
146
147    fn msrv_compat_count(&self, summary: &Summary) -> usize {
148        let Some(rust_version) = summary.rust_version() else {
149            return self.rust_versions.len();
150        };
151
152        self.rust_versions
153            .iter()
154            .filter(|max| rust_version.is_compatible_with(max))
155            .count()
156    }
157}
158
159/// Snapshot of the `min-publish-age` configuration before resolution started.
160#[derive(Debug)]
161pub struct PublishAgePolicy {
162    /// Reference "now" from [`GlobalContext::invocation_time`].
163    invocation_time: jiff::Timestamp,
164    /// `registry.global-min-publish-age`
165    global: MinPublishAge,
166    /// `registries.<name>.min-publish-age`
167    per_registry: HashMap<String, MinPublishAge>,
168}
169
170impl PublishAgePolicy {
171    /// Builds the policy from `min-publish-age` configuration.
172    ///
173    /// Returns `None` when either meets
174    ///
175    /// * the `-Zmin-publish-age` gate is off
176    /// * the resolver is configured to allow pubtime-incompatible versions
177    /// * no threshold is configured at all
178    pub fn new(now: Option<jiff::Timestamp>, gctx: &GlobalContext) -> CargoResult<Option<Self>> {
179        let resolver_config = gctx.get::<Option<CargoResolverConfig>>("resolver")?;
180        if resolver_config
181            .and_then(|c| c.incompatible_publish_age)
182            .is_some_and(|v| v == IncompatiblePublishAge::Allow)
183        {
184            return Ok(None);
185        }
186
187        Self::for_report(now, gctx)
188    }
189
190    /// Like [`PublishAgePolicy::new`] but ignore config from `[resolver]`,
191    /// so it report too-new packages regardess they are allowed or denied.
192    pub fn for_report(
193        now: Option<jiff::Timestamp>,
194        gctx: &GlobalContext,
195    ) -> CargoResult<Option<Self>> {
196        if !gctx.cli_unstable().min_publish_age {
197            return Ok(None);
198        }
199
200        let parse = |key: &str, config: Option<String>| -> CargoResult<MinPublishAge> {
201            let Some(config) = config else {
202                return Ok(MinPublishAge::Unset);
203            };
204            if config == "0" {
205                return Ok(MinPublishAge::None);
206            }
207            let duration = parse_time_span(&config)
208                .map_err(|e| anyhow::format_err!("invalid value for `{key}`: {e}"))?;
209            Ok(MinPublishAge::Age(duration, config))
210        };
211
212        let registry = gctx.get::<Option<GlobalRegistryConfig>>("registry")?;
213        let global = parse(
214            "registry.global-min-publish-age",
215            registry.and_then(|r| r.global_min_publish_age),
216        )?;
217        let mut per_registry = HashMap::default();
218        if let Some(registries) =
219            gctx.get::<Option<HashMap<String, RegistryConfig>>>("registries")?
220        {
221            for (name, config) in registries {
222                let limit = parse(
223                    &format!("registries.{name}.min-publish-age"),
224                    config.min_publish_age,
225                )?;
226                if limit.is_set() {
227                    per_registry.insert(name, limit);
228                }
229            }
230        }
231
232        let nothing_configured = !global.is_set() && per_registry.is_empty();
233        if nothing_configured {
234            return Ok(None);
235        }
236
237        Ok(Some(Self {
238            invocation_time: now.unwrap_or_else(|| gctx.invocation_time()),
239            global,
240            per_registry,
241        }))
242    }
243
244    /// Returns the version's publish-age if it is too new for its registry.
245    ///
246    /// `None` means the version is acceptable.
247    pub fn too_new(&self, summary: &Summary) -> Option<PublishAgeViolation> {
248        let pubtime = summary.pubtime()?;
249        let MinPublishAge::Age(min_age, config) = self.min_age(summary.source_id()) else {
250            return None;
251        };
252
253        let max_pubtime = jiff::SignedDuration::try_from(*min_age)
254            .ok()
255            .and_then(|min_age| self.invocation_time.checked_sub(min_age).ok());
256
257        let age = self.invocation_time.duration_since(pubtime);
258        let publish_age = || PublishAgeViolation {
259            age,
260            config: config.clone(),
261        };
262
263        match max_pubtime {
264            Some(max_pubtime) => (pubtime > max_pubtime).then(publish_age),
265            None => Some(publish_age()),
266        }
267    }
268
269    /// Resolves the minimum publish age for a given registry source.
270    ///
271    /// Priority:
272    ///
273    /// 1. `registries.<name>.min-publish-age`
274    /// 2. `registry.global-min-publish-age`
275    fn min_age(&self, source_id: SourceId) -> &MinPublishAge {
276        let name = source_id.alt_registry_key().or_else(|| {
277            // Our crates.io source ID has two forms:
278            // One with al_registry_key name and one without.
279            // SOURCE_ID_CACHE hold only one instance of them,
280            // so there may be a race.
281            // We need to fall back if missing one.
282            source_id.is_crates_io().then_some(CRATES_IO_REGISTRY)
283        });
284        if let Some(min_age) = name
285            .and_then(|name| self.per_registry.get(name))
286            .filter(|min_age| min_age.is_set())
287        {
288            return min_age;
289        }
290
291        &self.global
292    }
293
294    /// A single min-publish-age, if there is one
295    pub fn common_min_publish_age(&self) -> Option<PublishAgeViolation> {
296        if !self.per_registry.is_empty() {
297            return None;
298        }
299
300        if let MinPublishAge::Age(age, config) = &self.global {
301            jiff::SignedDuration::try_from(*age).ok().and_then(|age| {
302                Some(PublishAgeViolation {
303                    age,
304                    config: config.clone(),
305                })
306            })
307        } else {
308            None
309        }
310    }
311}
312
313/// A configured `min-publish-age` value for one scope.
314#[derive(Debug, Clone)]
315enum MinPublishAge {
316    /// Key unset.
317    Unset,
318    /// No min-publish-age limit at all.
319    None,
320    /// An age threshold, with the raw config string for display.
321    Age(Duration, String),
322}
323
324impl MinPublishAge {
325    /// Whether a value was configured for this scope.
326    fn is_set(&self) -> bool {
327        !matches!(self, MinPublishAge::Unset)
328    }
329}
330
331/// A violation of `min-publish-age` config.
332#[derive(Debug, Clone, PartialEq)]
333pub struct PublishAgeViolation {
334    /// How long ago the version was published.
335    age: jiff::SignedDuration,
336    /// The configured `min-publish-age` it violates
337    config: String,
338}
339
340impl PublishAgeViolation {
341    /// How long ago the version was published,
342    /// as a single friendly-spelled unit for display.
343    pub fn age_label(&self) -> String {
344        format_age_as_single_unit(self.age)
345    }
346
347    /// The configured `min-publish-age` it violates
348    pub fn config(&self) -> &str {
349        &self.config
350    }
351
352    /// A human-readable note describing the violation,
353    /// like `published 2 days ago, minimum age 7 days`.
354    pub fn note(&self) -> String {
355        let age = self.age_label();
356        let config = self.config();
357        format!("published {age} ago, minimum age {config}",)
358    }
359}
360
361/// Formats an age as a single, friendly-spelled unit, never is multi-unit noise.
362fn format_age_as_single_unit(age: jiff::SignedDuration) -> String {
363    use jiff::Unit;
364    use jiff::fmt::friendly::Designator;
365    use jiff::fmt::friendly::Spacing;
366    use jiff::fmt::friendly::SpanPrinter;
367
368    // An age at or ahead of "now" gives a non-positive age.
369    if age <= jiff::SignedDuration::ZERO {
370        return "moments".to_string();
371    }
372
373    let rounded = jiff::Span::try_from(age).and_then(|span| {
374        let unit = if age >= jiff::SignedDuration::from_hours(48) {
375            Unit::Day
376        } else if age >= jiff::SignedDuration::from_hours(1) {
377            Unit::Hour
378        } else if age >= jiff::SignedDuration::from_mins(1) {
379            Unit::Minute
380        } else {
381            Unit::Second
382        };
383        let opts = jiff::SpanRound::new()
384            .largest(unit)
385            .smallest(unit)
386            .relative(jiff::SpanRelativeTo::days_are_24_hours());
387        span.round(opts)
388    });
389
390    let printer = SpanPrinter::new()
391        .designator(Designator::Verbose)
392        .spacing(Spacing::BetweenUnitsAndDesignators);
393
394    match rounded {
395        Ok(span) => printer.span_to_string(&span).to_string(),
396        Err(e) => {
397            tracing::warn!("failed to round `{age}`: {e}");
398            format!("{} seconds", age.as_secs())
399        }
400    }
401}
402
403#[cfg(test)]
404mod test {
405    use super::*;
406    use crate::sources::CRATES_IO_INDEX;
407    use crate::sources::CRATES_IO_REGISTRY;
408    use crate::util::IntoUrl as _;
409    use crate::workspace::SourceId;
410
411    use std::collections::BTreeMap;
412
413    fn pkgid(name: &str, version: &str) -> PackageId {
414        let src_id =
415            SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index").unwrap();
416        PackageId::try_new(name, version, src_id).unwrap()
417    }
418
419    fn dep(name: &str, version: &str) -> Dependency {
420        let src_id =
421            SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index").unwrap();
422        Dependency::parse(name, Some(version), src_id).unwrap()
423    }
424
425    fn summ(name: &str, version: &str, msrv: Option<&str>) -> Summary {
426        let pkg_id = pkgid(name, version);
427        let features = BTreeMap::new();
428        Summary::new(
429            pkg_id,
430            Vec::new(),
431            &features,
432            None::<&String>,
433            msrv.map(|m| m.parse().unwrap()),
434        )
435        .unwrap()
436    }
437
438    fn describe(summaries: &Vec<Summary>) -> String {
439        let strs: Vec<String> = summaries
440            .iter()
441            .map(|summary| format!("{}/{}", summary.name(), summary.version()))
442            .collect();
443        strs.join(", ")
444    }
445
446    #[test]
447    fn test_prefer_package_id() {
448        let mut vp = VersionPreferences::default();
449        vp.prefer_package_id(pkgid("foo", "1.2.3"));
450
451        let mut summaries = vec![
452            summ("foo", "1.2.4", None),
453            summ("foo", "1.2.3", None),
454            summ("foo", "1.1.0", None),
455            summ("foo", "1.0.9", None),
456        ];
457
458        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
459        vp.sort_summaries(&mut summaries, None);
460        assert_eq!(
461            describe(&summaries),
462            "foo/1.2.3, foo/1.2.4, foo/1.1.0, foo/1.0.9".to_string()
463        );
464
465        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
466        vp.sort_summaries(&mut summaries, None);
467        assert_eq!(
468            describe(&summaries),
469            "foo/1.2.3, foo/1.0.9, foo/1.1.0, foo/1.2.4".to_string()
470        );
471    }
472
473    #[test]
474    fn test_prefer_dependency() {
475        let mut vp = VersionPreferences::default();
476        vp.prefer_dependency(dep("foo", "=1.2.3"));
477
478        let mut summaries = vec![
479            summ("foo", "1.2.4", None),
480            summ("foo", "1.2.3", None),
481            summ("foo", "1.1.0", None),
482            summ("foo", "1.0.9", None),
483        ];
484
485        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
486        vp.sort_summaries(&mut summaries, None);
487        assert_eq!(
488            describe(&summaries),
489            "foo/1.2.3, foo/1.2.4, foo/1.1.0, foo/1.0.9".to_string()
490        );
491
492        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
493        vp.sort_summaries(&mut summaries, None);
494        assert_eq!(
495            describe(&summaries),
496            "foo/1.2.3, foo/1.0.9, foo/1.1.0, foo/1.2.4".to_string()
497        );
498    }
499
500    #[test]
501    fn test_prefer_both() {
502        let mut vp = VersionPreferences::default();
503        vp.prefer_package_id(pkgid("foo", "1.2.3"));
504        vp.prefer_dependency(dep("foo", "=1.1.0"));
505
506        let mut summaries = vec![
507            summ("foo", "1.2.4", None),
508            summ("foo", "1.2.3", None),
509            summ("foo", "1.1.0", None),
510            summ("foo", "1.0.9", None),
511        ];
512
513        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
514        vp.sort_summaries(&mut summaries, None);
515        assert_eq!(
516            describe(&summaries),
517            "foo/1.2.3, foo/1.1.0, foo/1.2.4, foo/1.0.9".to_string()
518        );
519
520        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
521        vp.sort_summaries(&mut summaries, None);
522        assert_eq!(
523            describe(&summaries),
524            "foo/1.1.0, foo/1.2.3, foo/1.0.9, foo/1.2.4".to_string()
525        );
526    }
527
528    #[test]
529    fn test_single_rust_version() {
530        let mut vp = VersionPreferences::default();
531        vp.rust_versions(vec!["1.50".parse().unwrap()]);
532
533        let mut summaries = vec![
534            summ("foo", "1.2.4", None),
535            summ("foo", "1.2.3", Some("1.60")),
536            summ("foo", "1.2.2", None),
537            summ("foo", "1.2.1", Some("1.50")),
538            summ("foo", "1.2.0", None),
539            summ("foo", "1.1.0", Some("1.40")),
540            summ("foo", "1.0.9", None),
541        ];
542
543        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
544        vp.sort_summaries(&mut summaries, None);
545        assert_eq!(
546            describe(&summaries),
547            "foo/1.2.4, foo/1.2.2, foo/1.2.1, foo/1.2.0, foo/1.1.0, foo/1.0.9, foo/1.2.3"
548                .to_string()
549        );
550
551        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
552        vp.sort_summaries(&mut summaries, None);
553        assert_eq!(
554            describe(&summaries),
555            "foo/1.0.9, foo/1.1.0, foo/1.2.0, foo/1.2.1, foo/1.2.2, foo/1.2.4, foo/1.2.3"
556                .to_string()
557        );
558    }
559
560    #[test]
561    fn test_multiple_rust_versions() {
562        let mut vp = VersionPreferences::default();
563        vp.rust_versions(vec!["1.45".parse().unwrap(), "1.55".parse().unwrap()]);
564
565        let mut summaries = vec![
566            summ("foo", "1.2.4", None),
567            summ("foo", "1.2.3", Some("1.60")),
568            summ("foo", "1.2.2", None),
569            summ("foo", "1.2.1", Some("1.50")),
570            summ("foo", "1.2.0", None),
571            summ("foo", "1.1.0", Some("1.40")),
572            summ("foo", "1.0.9", None),
573        ];
574
575        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
576        vp.sort_summaries(&mut summaries, None);
577        assert_eq!(
578            describe(&summaries),
579            "foo/1.2.4, foo/1.2.2, foo/1.2.0, foo/1.1.0, foo/1.0.9, foo/1.2.1, foo/1.2.3"
580                .to_string()
581        );
582
583        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
584        vp.sort_summaries(&mut summaries, None);
585        assert_eq!(
586            describe(&summaries),
587            "foo/1.0.9, foo/1.1.0, foo/1.2.0, foo/1.2.2, foo/1.2.4, foo/1.2.1, foo/1.2.3"
588                .to_string()
589        );
590    }
591
592    #[test]
593    fn test_empty_summaries() {
594        let vp = VersionPreferences::default();
595        let mut summaries = vec![];
596
597        vp.sort_summaries(&mut summaries, Some(VersionOrdering::MaximumVersionsFirst));
598        assert_eq!(summaries, vec![]);
599    }
600
601    const NOW: &str = "2006-08-08T00:00:00Z";
602
603    fn age(raw: &str) -> MinPublishAge {
604        MinPublishAge::Age(parse_time_span(raw).unwrap(), raw.to_string())
605    }
606
607    fn hours(n: i64) -> jiff::SignedDuration {
608        jiff::SignedDuration::from_hours(n)
609    }
610
611    fn policy(global: MinPublishAge, per_registry: &[(&str, MinPublishAge)]) -> PublishAgePolicy {
612        PublishAgePolicy {
613            invocation_time: NOW.parse().unwrap(),
614            global,
615            per_registry: per_registry
616                .iter()
617                .map(|(name, age)| (name.to_string(), age.clone()))
618                .collect(),
619        }
620    }
621
622    fn crates_io_source() -> SourceId {
623        let url = CRATES_IO_INDEX.into_url().unwrap();
624        SourceId::for_alt_registry(&url, CRATES_IO_REGISTRY).unwrap()
625    }
626
627    fn alt_source() -> SourceId {
628        let url = "https://example.com/index".into_url().unwrap();
629        SourceId::for_alt_registry(&url, "alt").unwrap()
630    }
631
632    /// Gets a summary on `source`, published `age` before `NOW`.
633    /// If age is negative, it means it is published in the future.
634    fn published(source: SourceId, age: jiff::SignedDuration) -> Summary {
635        let pkg_id = PackageId::try_new("foo", "1.0.0", source).unwrap();
636        let mut summary =
637            Summary::new(pkg_id, Vec::new(), &BTreeMap::new(), None::<&String>, None).unwrap();
638        let now: jiff::Timestamp = NOW.parse().unwrap();
639        summary.set_pubtime(now - age);
640        summary
641    }
642
643    #[test]
644    fn publish_age_reports_exact_age() {
645        let p = policy(age("7 days"), &[]);
646        let violation = p.too_new(&published(crates_io_source(), hours(50)));
647        assert_eq!(
648            violation,
649            Some(PublishAgeViolation {
650                age: hours(50),
651                config: "7 days".to_string(),
652            })
653        );
654    }
655
656    #[test]
657    fn publish_age_older_than_threshold_is_acceptable() {
658        let p = policy(age("7 days"), &[]);
659        let violation = p.too_new(&published(crates_io_source(), hours(10 * 24)));
660        assert_eq!(violation, None);
661    }
662
663    #[test]
664    fn publish_age_at_threshold_boundary_is_acceptable() {
665        let p = policy(age("7 days"), &[]);
666        let violation = p.too_new(&published(crates_io_source(), hours(7 * 24)));
667        assert_eq!(violation, None);
668    }
669
670    #[test]
671    fn publish_age_just_inside_threshold_is_too_new() {
672        let p = policy(age("7 days"), &[]);
673        let violation = p.too_new(&published(crates_io_source(), hours(7 * 24 - 1)));
674        assert_eq!(
675            violation,
676            Some(PublishAgeViolation {
677                age: hours(7 * 24 - 1),
678                config: "7 days".to_string(),
679            })
680        );
681    }
682
683    #[test]
684    fn publish_age_per_registry_overrides_global() {
685        let p = policy(age("30 days"), &[("alt", age("1 day"))]);
686        let violation = p.too_new(&published(alt_source(), hours(2 * 24)));
687        assert_eq!(violation, None);
688    }
689
690    #[test]
691    fn publish_age_crates_io_scope_excludes_alt_registry() {
692        let p = policy(age("1 day"), &[(CRATES_IO_REGISTRY, age("30 days"))]);
693        let crates_io = p.too_new(&published(crates_io_source(), hours(2 * 24)));
694        let alt = p.too_new(&published(alt_source(), hours(2 * 24)));
695        assert_eq!(
696            crates_io,
697            Some(PublishAgeViolation {
698                age: hours(2 * 24),
699                config: "30 days".to_string(),
700            })
701        );
702        assert_eq!(alt, None);
703    }
704
705    #[test]
706    fn publish_age_alt_registry_falls_through_to_global() {
707        let p = policy(age("7 days"), &[]);
708        let violation = p.too_new(&published(alt_source(), hours(2 * 24)));
709        assert_eq!(
710            violation,
711            Some(PublishAgeViolation {
712                age: hours(2 * 24),
713                config: "7 days".to_string(),
714            })
715        );
716    }
717
718    #[test]
719    fn publish_age_per_registry_too_new() {
720        let p = policy(MinPublishAge::Unset, &[("alt", age("7 days"))]);
721        let violation = p.too_new(&published(alt_source(), hours(2 * 24)));
722        assert_eq!(
723            violation,
724            Some(PublishAgeViolation {
725                age: hours(2 * 24),
726                config: "7 days".to_string(),
727            })
728        );
729    }
730
731    #[test]
732    fn publish_age_per_registry_zero_overrides_global() {
733        let p = policy(age("30 days"), &[("alt", MinPublishAge::None)]);
734        let violation = p.too_new(&published(alt_source(), hours(0)));
735        assert_eq!(violation, None);
736    }
737
738    #[test]
739    fn publish_age_no_applicable_scope_is_acceptable() {
740        let p = policy(MinPublishAge::Unset, &[(CRATES_IO_REGISTRY, age("7 days"))]);
741        let violation = p.too_new(&published(alt_source(), hours(0)));
742        assert_eq!(violation, None);
743    }
744
745    #[test]
746    fn publish_age_zero_disables_threshold() {
747        let p = policy(MinPublishAge::None, &[]);
748        let violation = p.too_new(&published(crates_io_source(), hours(0)));
749        assert_eq!(violation, None);
750    }
751
752    #[test]
753    fn publish_age_zero_stops_scope_fallthrough() {
754        let p = policy(age("30 days"), &[(CRATES_IO_REGISTRY, MinPublishAge::None)]);
755        let violation = p.too_new(&published(crates_io_source(), hours(0)));
756        assert_eq!(violation, None);
757    }
758
759    #[test]
760    fn publish_age_missing_pubtime_is_acceptable() {
761        let p = policy(age("7 days"), &[]);
762        let pkg_id = PackageId::try_new("foo", "1.0.0", crates_io_source()).unwrap();
763        let summary =
764            Summary::new(pkg_id, Vec::new(), &BTreeMap::new(), None::<&String>, None).unwrap();
765        let violation = p.too_new(&summary);
766        assert_eq!(violation, None);
767    }
768
769    #[test]
770    fn publish_age_future_pubtime_is_too_new() {
771        let p = policy(age("7 days"), &[]);
772        let violation = p.too_new(&published(crates_io_source(), hours(-24)));
773        assert_eq!(
774            violation,
775            Some(PublishAgeViolation {
776                age: hours(-24),
777                config: "7 days".to_string(),
778            })
779        );
780    }
781
782    #[test]
783    fn publish_age_out_of_range_threshold_is_too_new() {
784        // u64::MAX
785        let p = policy(age("18446744073709551615 seconds"), &[]);
786        let violation = p.too_new(&published(crates_io_source(), hours(24)));
787        assert_eq!(
788            violation,
789            Some(PublishAgeViolation {
790                age: hours(24),
791                config: "18446744073709551615 seconds".to_string(),
792            })
793        );
794    }
795
796    #[track_caller]
797    fn assert_age(secs: i64, expected: &str) {
798        assert_eq!(
799            format_age_as_single_unit(jiff::SignedDuration::from_secs(secs)),
800            expected
801        );
802    }
803
804    const MIN: i64 = 60;
805    const HOUR: i64 = 60 * MIN;
806    const DAY: i64 = 24 * HOUR;
807
808    #[test]
809    fn rounds_to_a_single_unit() {
810        // `>= 2 days` rounds to the nearest day.
811        assert_age(2 * DAY, "2 days");
812        assert_age(2 * DAY + 8 * HOUR + 23 * MIN, "2 days");
813        assert_age(2 * DAY + 13 * HOUR, "3 days");
814        assert_age(540 * DAY, "540 days");
815
816        // `1 hour ..< 2 days` rounds to the nearest hour.
817        assert_age(47 * HOUR, "47 hours");
818        assert_age(24 * HOUR, "24 hours");
819        assert_age(11 * HOUR + 40 * MIN, "12 hours");
820        assert_age(11 * HOUR + 20 * MIN, "11 hours");
821        assert_age(HOUR, "1 hour");
822
823        // `1 minute ..< 1 hour` rounds to the nearest minute.
824        assert_age(40 * MIN, "40 minutes");
825        assert_age(MIN, "1 minute");
826
827        // `< 1 minute` rounds to the nearest second.
828        assert_age(40, "40 seconds");
829        assert_age(1, "1 second");
830
831        // ahead of "now" (clock drift)
832        assert_age(0, "moments");
833        assert_age(-20, "moments");
834        assert_age(-2 * DAY, "moments");
835    }
836}