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