Skip to main content

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