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::util::CargoResult;
15use crate::util::GlobalContext;
16use crate::util::interning::InternedString;
17use crate::util::time_span::parse_time_span;
18use crate::workspace::Dependency;
19use crate::workspace::PackageId;
20use crate::workspace::SourceId;
21use crate::workspace::Summary;
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(now: Option<jiff::Timestamp>, 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(now, 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(
194        now: Option<jiff::Timestamp>,
195        gctx: &GlobalContext,
196    ) -> CargoResult<Option<Self>> {
197        if !gctx.cli_unstable().min_publish_age {
198            return Ok(None);
199        }
200
201        let parse = |key: &str, config: Option<String>| -> CargoResult<MinPublishAge> {
202            let Some(config) = config else {
203                return Ok(MinPublishAge::Unset);
204            };
205            if config == "0" {
206                return Ok(MinPublishAge::None);
207            }
208            let duration = parse_time_span(&config)
209                .map_err(|e| anyhow::format_err!("invalid value for `{key}`: {e}"))?;
210            Ok(MinPublishAge::Age(duration, config))
211        };
212
213        let registry = gctx.get::<Option<GlobalRegistryConfig>>("registry")?;
214        let global = parse(
215            "registry.global-min-publish-age",
216            registry
217                .as_ref()
218                .and_then(|r| r.global_min_publish_age.clone()),
219        )?;
220        let crates_io = parse(
221            "registry.min-publish-age",
222            registry.and_then(|r| r.min_publish_age),
223        )?;
224        let mut per_registry = HashMap::default();
225        if let Some(registries) =
226            gctx.get::<Option<HashMap<String, RegistryConfig>>>("registries")?
227        {
228            for (name, config) in registries {
229                let limit = parse(
230                    &format!("registries.{name}.min-publish-age"),
231                    config.min_publish_age,
232                )?;
233                if limit.is_set() {
234                    per_registry.insert(name, limit);
235                }
236            }
237        }
238
239        let nothing_configured = !global.is_set() && !crates_io.is_set() && per_registry.is_empty();
240        if nothing_configured {
241            return Ok(None);
242        }
243
244        Ok(Some(Self {
245            invocation_time: now.unwrap_or_else(|| gctx.invocation_time()),
246            global,
247            crates_io,
248            per_registry,
249        }))
250    }
251
252    /// Returns the version's publish-age if it is too new for its registry.
253    ///
254    /// `None` means the version is acceptable.
255    pub fn too_new(&self, summary: &Summary) -> Option<PublishAgeViolation> {
256        let pubtime = summary.pubtime()?;
257        let MinPublishAge::Age(min_age, config) = self.min_age(summary.source_id()) else {
258            return None;
259        };
260
261        let max_pubtime = jiff::SignedDuration::try_from(*min_age)
262            .ok()
263            .and_then(|min_age| self.invocation_time.checked_sub(min_age).ok());
264
265        let age = self.invocation_time.duration_since(pubtime);
266        let publish_age = || PublishAgeViolation {
267            age,
268            config: config.clone(),
269        };
270
271        match max_pubtime {
272            Some(max_pubtime) => (pubtime > max_pubtime).then(publish_age),
273            None => Some(publish_age()),
274        }
275    }
276
277    /// Resolves the minimum publish age for a given registry source.
278    ///
279    /// Priority:
280    ///
281    /// 1. `registries.<name>.min-publish-age`
282    /// 2. `registry.min-publish-age`
283    /// 3. `registry.global-min-publish-age`
284    fn min_age(&self, source_id: SourceId) -> &MinPublishAge {
285        // `registries.<name>` also covers crates.io, whose name is `crates-io`.
286        if let Some(min_age) = source_id
287            .alt_registry_key()
288            .and_then(|name| self.per_registry.get(name))
289            .filter(|min_age| min_age.is_set())
290        {
291            return min_age;
292        }
293
294        if source_id.is_crates_io() && self.crates_io.is_set() {
295            return &self.crates_io;
296        }
297
298        &self.global
299    }
300
301    /// A single min-publish-age, if there is one
302    pub fn common_min_publish_age(&self) -> Option<PublishAgeViolation> {
303        if !self.per_registry.is_empty() {
304            return None;
305        }
306
307        if self.crates_io.is_set() {
308            return None;
309        }
310
311        if let MinPublishAge::Age(age, config) = &self.global {
312            jiff::SignedDuration::try_from(*age).ok().and_then(|age| {
313                Some(PublishAgeViolation {
314                    age,
315                    config: config.clone(),
316                })
317            })
318        } else {
319            None
320        }
321    }
322}
323
324/// A configured `min-publish-age` value for one scope.
325#[derive(Debug, Clone)]
326enum MinPublishAge {
327    /// Key unset.
328    Unset,
329    /// No min-publish-age limit at all.
330    None,
331    /// An age threshold, with the raw config string for display.
332    Age(Duration, String),
333}
334
335impl MinPublishAge {
336    /// Whether a value was configured for this scope.
337    fn is_set(&self) -> bool {
338        !matches!(self, MinPublishAge::Unset)
339    }
340}
341
342/// A violation of `min-publish-age` config.
343#[derive(Debug, Clone, PartialEq)]
344pub struct PublishAgeViolation {
345    /// How long ago the version was published.
346    age: jiff::SignedDuration,
347    /// The configured `min-publish-age` it violates
348    config: String,
349}
350
351impl PublishAgeViolation {
352    /// How long ago the version was published,
353    /// as a single friendly-spelled unit for display.
354    pub fn age_label(&self) -> String {
355        format_age_as_single_unit(self.age)
356    }
357
358    /// The configured `min-publish-age` it violates
359    pub fn config(&self) -> &str {
360        &self.config
361    }
362
363    /// A human-readable note describing the violation,
364    /// like `published 2 days ago, minimum age 7 days`.
365    pub fn note(&self) -> String {
366        let age = self.age_label();
367        let config = self.config();
368        format!("published {age} ago, minimum age {config}",)
369    }
370}
371
372/// Formats an age as a single, friendly-spelled unit, never is multi-unit noise.
373fn format_age_as_single_unit(age: jiff::SignedDuration) -> String {
374    use jiff::Unit;
375    use jiff::fmt::friendly::Designator;
376    use jiff::fmt::friendly::Spacing;
377    use jiff::fmt::friendly::SpanPrinter;
378
379    // An age at or ahead of "now" gives a non-positive age.
380    if age <= jiff::SignedDuration::ZERO {
381        return "moments".to_string();
382    }
383
384    let rounded = jiff::Span::try_from(age).and_then(|span| {
385        let unit = if age >= jiff::SignedDuration::from_hours(48) {
386            Unit::Day
387        } else if age >= jiff::SignedDuration::from_hours(1) {
388            Unit::Hour
389        } else if age >= jiff::SignedDuration::from_mins(1) {
390            Unit::Minute
391        } else {
392            Unit::Second
393        };
394        let opts = jiff::SpanRound::new()
395            .largest(unit)
396            .smallest(unit)
397            .relative(jiff::SpanRelativeTo::days_are_24_hours());
398        span.round(opts)
399    });
400
401    let printer = SpanPrinter::new()
402        .designator(Designator::Verbose)
403        .spacing(Spacing::BetweenUnitsAndDesignators);
404
405    match rounded {
406        Ok(span) => printer.span_to_string(&span).to_string(),
407        Err(e) => {
408            tracing::warn!("failed to round `{age}`: {e}");
409            format!("{} seconds", age.as_secs())
410        }
411    }
412}
413
414#[cfg(test)]
415mod test {
416    use super::*;
417    use crate::sources::CRATES_IO_INDEX;
418    use crate::sources::CRATES_IO_REGISTRY;
419    use crate::util::IntoUrl as _;
420    use crate::workspace::SourceId;
421
422    use std::collections::BTreeMap;
423
424    fn pkgid(name: &str, version: &str) -> PackageId {
425        let src_id =
426            SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index").unwrap();
427        PackageId::try_new(name, version, src_id).unwrap()
428    }
429
430    fn dep(name: &str, version: &str) -> Dependency {
431        let src_id =
432            SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index").unwrap();
433        Dependency::parse(name, Some(version), src_id).unwrap()
434    }
435
436    fn summ(name: &str, version: &str, msrv: Option<&str>) -> Summary {
437        let pkg_id = pkgid(name, version);
438        let features = BTreeMap::new();
439        Summary::new(
440            pkg_id,
441            Vec::new(),
442            &features,
443            None::<&String>,
444            msrv.map(|m| m.parse().unwrap()),
445        )
446        .unwrap()
447    }
448
449    fn describe(summaries: &Vec<Summary>) -> String {
450        let strs: Vec<String> = summaries
451            .iter()
452            .map(|summary| format!("{}/{}", summary.name(), summary.version()))
453            .collect();
454        strs.join(", ")
455    }
456
457    #[test]
458    fn test_prefer_package_id() {
459        let mut vp = VersionPreferences::default();
460        vp.prefer_package_id(pkgid("foo", "1.2.3"));
461
462        let mut summaries = vec![
463            summ("foo", "1.2.4", None),
464            summ("foo", "1.2.3", None),
465            summ("foo", "1.1.0", None),
466            summ("foo", "1.0.9", None),
467        ];
468
469        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
470        vp.sort_summaries(&mut summaries, None);
471        assert_eq!(
472            describe(&summaries),
473            "foo/1.2.3, foo/1.2.4, foo/1.1.0, foo/1.0.9".to_string()
474        );
475
476        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
477        vp.sort_summaries(&mut summaries, None);
478        assert_eq!(
479            describe(&summaries),
480            "foo/1.2.3, foo/1.0.9, foo/1.1.0, foo/1.2.4".to_string()
481        );
482    }
483
484    #[test]
485    fn test_prefer_dependency() {
486        let mut vp = VersionPreferences::default();
487        vp.prefer_dependency(dep("foo", "=1.2.3"));
488
489        let mut summaries = vec![
490            summ("foo", "1.2.4", None),
491            summ("foo", "1.2.3", None),
492            summ("foo", "1.1.0", None),
493            summ("foo", "1.0.9", None),
494        ];
495
496        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
497        vp.sort_summaries(&mut summaries, None);
498        assert_eq!(
499            describe(&summaries),
500            "foo/1.2.3, foo/1.2.4, foo/1.1.0, foo/1.0.9".to_string()
501        );
502
503        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
504        vp.sort_summaries(&mut summaries, None);
505        assert_eq!(
506            describe(&summaries),
507            "foo/1.2.3, foo/1.0.9, foo/1.1.0, foo/1.2.4".to_string()
508        );
509    }
510
511    #[test]
512    fn test_prefer_both() {
513        let mut vp = VersionPreferences::default();
514        vp.prefer_package_id(pkgid("foo", "1.2.3"));
515        vp.prefer_dependency(dep("foo", "=1.1.0"));
516
517        let mut summaries = vec![
518            summ("foo", "1.2.4", None),
519            summ("foo", "1.2.3", None),
520            summ("foo", "1.1.0", None),
521            summ("foo", "1.0.9", None),
522        ];
523
524        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
525        vp.sort_summaries(&mut summaries, None);
526        assert_eq!(
527            describe(&summaries),
528            "foo/1.2.3, foo/1.1.0, foo/1.2.4, foo/1.0.9".to_string()
529        );
530
531        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
532        vp.sort_summaries(&mut summaries, None);
533        assert_eq!(
534            describe(&summaries),
535            "foo/1.1.0, foo/1.2.3, foo/1.0.9, foo/1.2.4".to_string()
536        );
537    }
538
539    #[test]
540    fn test_single_rust_version() {
541        let mut vp = VersionPreferences::default();
542        vp.rust_versions(vec!["1.50".parse().unwrap()]);
543
544        let mut summaries = vec![
545            summ("foo", "1.2.4", None),
546            summ("foo", "1.2.3", Some("1.60")),
547            summ("foo", "1.2.2", None),
548            summ("foo", "1.2.1", Some("1.50")),
549            summ("foo", "1.2.0", None),
550            summ("foo", "1.1.0", Some("1.40")),
551            summ("foo", "1.0.9", None),
552        ];
553
554        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
555        vp.sort_summaries(&mut summaries, None);
556        assert_eq!(
557            describe(&summaries),
558            "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"
559                .to_string()
560        );
561
562        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
563        vp.sort_summaries(&mut summaries, None);
564        assert_eq!(
565            describe(&summaries),
566            "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"
567                .to_string()
568        );
569    }
570
571    #[test]
572    fn test_multiple_rust_versions() {
573        let mut vp = VersionPreferences::default();
574        vp.rust_versions(vec!["1.45".parse().unwrap(), "1.55".parse().unwrap()]);
575
576        let mut summaries = vec![
577            summ("foo", "1.2.4", None),
578            summ("foo", "1.2.3", Some("1.60")),
579            summ("foo", "1.2.2", None),
580            summ("foo", "1.2.1", Some("1.50")),
581            summ("foo", "1.2.0", None),
582            summ("foo", "1.1.0", Some("1.40")),
583            summ("foo", "1.0.9", None),
584        ];
585
586        vp.version_ordering(VersionOrdering::MaximumVersionsFirst);
587        vp.sort_summaries(&mut summaries, None);
588        assert_eq!(
589            describe(&summaries),
590            "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"
591                .to_string()
592        );
593
594        vp.version_ordering(VersionOrdering::MinimumVersionsFirst);
595        vp.sort_summaries(&mut summaries, None);
596        assert_eq!(
597            describe(&summaries),
598            "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"
599                .to_string()
600        );
601    }
602
603    #[test]
604    fn test_empty_summaries() {
605        let vp = VersionPreferences::default();
606        let mut summaries = vec![];
607
608        vp.sort_summaries(&mut summaries, Some(VersionOrdering::MaximumVersionsFirst));
609        assert_eq!(summaries, vec![]);
610    }
611
612    const NOW: &str = "2006-08-08T00:00:00Z";
613
614    fn age(raw: &str) -> MinPublishAge {
615        MinPublishAge::Age(parse_time_span(raw).unwrap(), raw.to_string())
616    }
617
618    fn hours(n: i64) -> jiff::SignedDuration {
619        jiff::SignedDuration::from_hours(n)
620    }
621
622    fn policy(
623        global: MinPublishAge,
624        crates_io: MinPublishAge,
625        per_registry: &[(&str, MinPublishAge)],
626    ) -> PublishAgePolicy {
627        PublishAgePolicy {
628            invocation_time: NOW.parse().unwrap(),
629            global,
630            crates_io,
631            per_registry: per_registry
632                .iter()
633                .map(|(name, age)| (name.to_string(), age.clone()))
634                .collect(),
635        }
636    }
637
638    fn crates_io_source() -> SourceId {
639        let url = CRATES_IO_INDEX.into_url().unwrap();
640        SourceId::for_alt_registry(&url, CRATES_IO_REGISTRY).unwrap()
641    }
642
643    fn alt_source() -> SourceId {
644        let url = "https://example.com/index".into_url().unwrap();
645        SourceId::for_alt_registry(&url, "alt").unwrap()
646    }
647
648    /// Gets a summary on `source`, published `age` before `NOW`.
649    /// If age is negative, it means it is published in the future.
650    fn published(source: SourceId, age: jiff::SignedDuration) -> Summary {
651        let pkg_id = PackageId::try_new("foo", "1.0.0", source).unwrap();
652        let mut summary =
653            Summary::new(pkg_id, Vec::new(), &BTreeMap::new(), None::<&String>, None).unwrap();
654        let now: jiff::Timestamp = NOW.parse().unwrap();
655        summary.set_pubtime(now - age);
656        summary
657    }
658
659    #[test]
660    fn publish_age_reports_exact_age() {
661        let p = policy(age("7 days"), MinPublishAge::Unset, &[]);
662        let violation = p.too_new(&published(crates_io_source(), hours(50)));
663        assert_eq!(
664            violation,
665            Some(PublishAgeViolation {
666                age: hours(50),
667                config: "7 days".to_string(),
668            })
669        );
670    }
671
672    #[test]
673    fn publish_age_older_than_threshold_is_acceptable() {
674        let p = policy(age("7 days"), MinPublishAge::Unset, &[]);
675        let violation = p.too_new(&published(crates_io_source(), hours(10 * 24)));
676        assert_eq!(violation, None);
677    }
678
679    #[test]
680    fn publish_age_at_threshold_boundary_is_acceptable() {
681        let p = policy(age("7 days"), MinPublishAge::Unset, &[]);
682        let violation = p.too_new(&published(crates_io_source(), hours(7 * 24)));
683        assert_eq!(violation, None);
684    }
685
686    #[test]
687    fn publish_age_just_inside_threshold_is_too_new() {
688        let p = policy(age("7 days"), MinPublishAge::Unset, &[]);
689        let violation = p.too_new(&published(crates_io_source(), hours(7 * 24 - 1)));
690        assert_eq!(
691            violation,
692            Some(PublishAgeViolation {
693                age: hours(7 * 24 - 1),
694                config: "7 days".to_string(),
695            })
696        );
697    }
698
699    #[test]
700    fn publish_age_per_registry_overrides_global() {
701        let p = policy(
702            age("30 days"),
703            MinPublishAge::Unset,
704            &[("alt", age("1 day"))],
705        );
706        let violation = p.too_new(&published(alt_source(), hours(2 * 24)));
707        assert_eq!(violation, None);
708    }
709
710    #[test]
711    fn publish_age_crates_io_scope_excludes_alt_registry() {
712        let p = policy(age("1 day"), age("30 days"), &[]);
713        let crates_io = p.too_new(&published(crates_io_source(), hours(2 * 24)));
714        let alt = p.too_new(&published(alt_source(), hours(2 * 24)));
715        assert_eq!(
716            crates_io,
717            Some(PublishAgeViolation {
718                age: hours(2 * 24),
719                config: "30 days".to_string(),
720            })
721        );
722        assert_eq!(alt, None);
723    }
724
725    #[test]
726    fn publish_age_alt_registry_falls_through_to_global() {
727        let p = policy(age("7 days"), MinPublishAge::Unset, &[]);
728        let violation = p.too_new(&published(alt_source(), hours(2 * 24)));
729        assert_eq!(
730            violation,
731            Some(PublishAgeViolation {
732                age: hours(2 * 24),
733                config: "7 days".to_string(),
734            })
735        );
736    }
737
738    #[test]
739    fn publish_age_per_registry_too_new() {
740        let p = policy(
741            MinPublishAge::Unset,
742            MinPublishAge::Unset,
743            &[("alt", age("7 days"))],
744        );
745        let violation = p.too_new(&published(alt_source(), hours(2 * 24)));
746        assert_eq!(
747            violation,
748            Some(PublishAgeViolation {
749                age: hours(2 * 24),
750                config: "7 days".to_string(),
751            })
752        );
753    }
754
755    #[test]
756    fn publish_age_per_registry_zero_overrides_global() {
757        let p = policy(
758            age("30 days"),
759            MinPublishAge::Unset,
760            &[("alt", MinPublishAge::None)],
761        );
762        let violation = p.too_new(&published(alt_source(), hours(0)));
763        assert_eq!(violation, None);
764    }
765
766    #[test]
767    fn publish_age_no_applicable_scope_is_acceptable() {
768        let p = policy(MinPublishAge::Unset, age("7 days"), &[]);
769        let violation = p.too_new(&published(alt_source(), hours(0)));
770        assert_eq!(violation, None);
771    }
772
773    #[test]
774    fn publish_age_zero_disables_threshold() {
775        let p = policy(MinPublishAge::None, MinPublishAge::Unset, &[]);
776        let violation = p.too_new(&published(crates_io_source(), hours(0)));
777        assert_eq!(violation, None);
778    }
779
780    #[test]
781    fn publish_age_zero_stops_scope_fallthrough() {
782        let p = policy(age("30 days"), MinPublishAge::None, &[]);
783        let violation = p.too_new(&published(crates_io_source(), hours(0)));
784        assert_eq!(violation, None);
785    }
786
787    #[test]
788    fn publish_age_missing_pubtime_is_acceptable() {
789        let p = policy(age("7 days"), MinPublishAge::Unset, &[]);
790        let pkg_id = PackageId::try_new("foo", "1.0.0", crates_io_source()).unwrap();
791        let summary =
792            Summary::new(pkg_id, Vec::new(), &BTreeMap::new(), None::<&String>, None).unwrap();
793        let violation = p.too_new(&summary);
794        assert_eq!(violation, None);
795    }
796
797    #[test]
798    fn publish_age_future_pubtime_is_too_new() {
799        let p = policy(age("7 days"), MinPublishAge::Unset, &[]);
800        let violation = p.too_new(&published(crates_io_source(), hours(-24)));
801        assert_eq!(
802            violation,
803            Some(PublishAgeViolation {
804                age: hours(-24),
805                config: "7 days".to_string(),
806            })
807        );
808    }
809
810    #[test]
811    fn publish_age_out_of_range_threshold_is_too_new() {
812        // u64::MAX
813        let p = policy(
814            age("18446744073709551615 seconds"),
815            MinPublishAge::Unset,
816            &[],
817        );
818        let violation = p.too_new(&published(crates_io_source(), hours(24)));
819        assert_eq!(
820            violation,
821            Some(PublishAgeViolation {
822                age: hours(24),
823                config: "18446744073709551615 seconds".to_string(),
824            })
825        );
826    }
827
828    #[track_caller]
829    fn assert_age(secs: i64, expected: &str) {
830        assert_eq!(
831            format_age_as_single_unit(jiff::SignedDuration::from_secs(secs)),
832            expected
833        );
834    }
835
836    const MIN: i64 = 60;
837    const HOUR: i64 = 60 * MIN;
838    const DAY: i64 = 24 * HOUR;
839
840    #[test]
841    fn rounds_to_a_single_unit() {
842        // `>= 2 days` rounds to the nearest day.
843        assert_age(2 * DAY, "2 days");
844        assert_age(2 * DAY + 8 * HOUR + 23 * MIN, "2 days");
845        assert_age(2 * DAY + 13 * HOUR, "3 days");
846        assert_age(540 * DAY, "540 days");
847
848        // `1 hour ..< 2 days` rounds to the nearest hour.
849        assert_age(47 * HOUR, "47 hours");
850        assert_age(24 * HOUR, "24 hours");
851        assert_age(11 * HOUR + 40 * MIN, "12 hours");
852        assert_age(11 * HOUR + 20 * MIN, "11 hours");
853        assert_age(HOUR, "1 hour");
854
855        // `1 minute ..< 1 hour` rounds to the nearest minute.
856        assert_age(40 * MIN, "40 minutes");
857        assert_age(MIN, "1 minute");
858
859        // `< 1 minute` rounds to the nearest second.
860        assert_age(40, "40 seconds");
861        assert_age(1, "1 second");
862
863        // ahead of "now" (clock drift)
864        assert_age(0, "moments");
865        assert_age(-20, "moments");
866        assert_age(-2 * DAY, "moments");
867    }
868}