| //! This module implements support for preferring some versions of a package | |
| //! over other versions. | |
| use cargo_util::collections::{HashMap, HashSet}; | |
| use std::cmp::Ordering; | |
| use std::time::Duration; | |
| use cargo_util_schemas::core::PartialVersion; | |
| use crate::context::CargoResolverConfig; | |
| use crate::context::GlobalRegistryConfig; | |
| use crate::context::IncompatiblePublishAge; | |
| use crate::context::RegistryConfig; | |
| use crate::sources::CRATES_IO_REGISTRY; | |
| use crate::util::CargoResult; | |
| use crate::util::GlobalContext; | |
| use crate::util::VersionReqMatchMode; | |
| use crate::util::interning::InternedString; | |
| use crate::util::time_span::parse_time_span; | |
| use crate::workspace::Dependency; | |
| use crate::workspace::PackageId; | |
| use crate::workspace::SourceId; | |
| use crate::workspace::Summary; | |
| /// A collection of preferences for particular package versions. | |
| /// | |
| /// This is built up with [`Self::prefer_package_id`] and [`Self::prefer_dependency`], then used to sort the set of | |
| /// summaries for a package during resolution via [`Self::sort_summaries`]. | |
| /// | |
| /// As written, a version is either "preferred" or "not preferred". Later extensions may | |
| /// introduce more granular preferences. | |
| #[derive(Default)] | |
| pub struct VersionPreferences { | |
| try_to_use: HashSet<PackageId>, | |
| prefer_patch_deps: HashMap<InternedString, HashSet<Dependency>>, | |
| version_ordering: VersionOrdering, | |
| rust_versions: Vec<PartialVersion>, | |
| publish_time: Option<jiff::Timestamp>, | |
| publish_age: Option<PublishAgePolicy>, | |
| } | |
| #[derive(Copy, Clone, Default, PartialEq, Eq, Hash, Debug)] | |
| pub enum VersionOrdering { | |
| #[default] | |
| MaximumVersionsFirst, | |
| MinimumVersionsFirst, | |
| } | |
| impl VersionPreferences { | |
| /// Indicate that the given package (specified as a [`PackageId`]) should be preferred. | |
| pub fn prefer_package_id(&mut self, pkg_id: PackageId) { | |
| self.try_to_use.insert(pkg_id); | |
| } | |
| /// Indicate that the given package (specified as a [`Dependency`]) should be preferred. | |
| pub fn prefer_dependency(&mut self, dep: Dependency) { | |
| self.prefer_patch_deps | |
| .entry(dep.package_name()) | |
| .or_insert_with(HashSet::default) | |
| .insert(dep); | |
| } | |
| pub fn version_ordering(&mut self, ordering: VersionOrdering) { | |
| self.version_ordering = ordering; | |
| } | |
| pub fn rust_versions(&mut self, vers: Vec<PartialVersion>) { | |
| self.rust_versions = vers; | |
| } | |
| pub fn publish_time(&mut self, publish_time: jiff::Timestamp) { | |
| self.publish_time = Some(publish_time); | |
| } | |
| pub fn publish_age(&mut self, policy: PublishAgePolicy) { | |
| self.publish_age = Some(policy); | |
| } | |
| /// Returns the version's publish-age if it is too new for the configured | |
| /// `min-publish-age`, otherwise `None`. | |
| pub fn too_new(&self, summary: &Summary) -> Option<PublishAgeViolation> { | |
| self.publish_age.as_ref()?.too_new(summary) | |
| } | |
| /// Whether the given package is preferred. | |
| pub fn should_prefer(&self, pkg_id: &PackageId) -> bool { | |
| self.try_to_use.contains(pkg_id) | |
| || self | |
| .prefer_patch_deps | |
| .get(&pkg_id.name()) | |
| .map(|deps| { | |
| deps.iter() | |
| .any(|d| d.matches_id(*pkg_id, VersionReqMatchMode::Default)) | |
| }) | |
| .unwrap_or(false) | |
| } | |
| /// Sort (and filter) the given vector of summaries in-place | |
| /// | |
| /// Note: all summaries presumed to be for the same package. | |
| /// | |
| /// Sort order: | |
| /// 1. Preferred packages | |
| /// 2. Most compatible [`VersionPreferences::rust_versions`] | |
| /// 3. `first_version`, falling back to [`VersionPreferences::version_ordering`] when `None` | |
| /// | |
| /// Filtering: | |
| /// - `publish_time` | |
| /// - `first_version` | |
| pub fn sort_summaries( | |
| &self, | |
| summaries: &mut Vec<Summary>, | |
| first_version: Option<VersionOrdering>, | |
| ) { | |
| if let Some(max_publish_time) = self.publish_time { | |
| summaries.retain(|s| { | |
| if let Some(summary_publish_time) = s.pubtime() { | |
| summary_publish_time <= max_publish_time | |
| } else { | |
| true | |
| } | |
| }); | |
| } | |
| summaries.sort_unstable_by(|a, b| { | |
| let prefer_a = self.should_prefer(&a.package_id()); | |
| let prefer_b = self.should_prefer(&b.package_id()); | |
| let previous_cmp = prefer_a.cmp(&prefer_b).reverse(); | |
| if previous_cmp != Ordering::Equal { | |
| return previous_cmp; | |
| } | |
| if !self.rust_versions.is_empty() { | |
| let a_compat_count = self.msrv_compat_count(a); | |
| let b_compat_count = self.msrv_compat_count(b); | |
| if b_compat_count != a_compat_count { | |
| return b_compat_count.cmp(&a_compat_count); | |
| } | |
| } | |
| let cmp = a.version().cmp(b.version()); | |
| match first_version.unwrap_or(self.version_ordering) { | |
| VersionOrdering::MaximumVersionsFirst => cmp.reverse(), | |
| VersionOrdering::MinimumVersionsFirst => cmp, | |
| } | |
| }); | |
| if first_version.is_some() && !summaries.is_empty() { | |
| let _ = summaries.split_off(1); | |
| } | |
| } | |
| fn msrv_compat_count(&self, summary: &Summary) -> usize { | |
| let Some(rust_version) = summary.rust_version() else { | |
| return self.rust_versions.len(); | |
| }; | |
| self.rust_versions | |
| .iter() | |
| .filter(|max| rust_version.is_compatible_with(max)) | |
| .count() | |
| } | |
| } | |
| /// Snapshot of the `min-publish-age` configuration before resolution started. | |
| #[derive(Debug)] | |
| pub struct PublishAgePolicy { | |
| /// Reference "now" from [`GlobalContext::invocation_time`]. | |
| invocation_time: jiff::Timestamp, | |
| /// `registry.global-min-publish-age` | |
| global: MinPublishAge, | |
| /// `registries.<name>.min-publish-age` | |
| per_registry: HashMap<String, MinPublishAge>, | |
| } | |
| impl PublishAgePolicy { | |
| /// Builds the policy from `min-publish-age` configuration. | |
| /// | |
| /// Returns `None` when either meets | |
| /// | |
| /// * the resolver is configured to allow pubtime-incompatible versions | |
| /// * no threshold is configured at all | |
| pub fn new(now: Option<jiff::Timestamp>, gctx: &GlobalContext) -> CargoResult<Option<Self>> { | |
| let resolver_config = gctx.get::<Option<CargoResolverConfig>>(["resolver"])?; | |
| if resolver_config | |
| .and_then(|c| c.incompatible_publish_age) | |
| .is_some_and(|v| v == IncompatiblePublishAge::Allow) | |
| { | |
| return Ok(None); | |
| } | |
| Self::for_report(now, gctx) | |
| } | |
| /// Like [`PublishAgePolicy::new`] but ignore config from `[resolver]`, | |
| /// so it report too-new packages regardess they are allowed or denied. | |
| pub fn for_report( | |
| now: Option<jiff::Timestamp>, | |
| gctx: &GlobalContext, | |
| ) -> CargoResult<Option<Self>> { | |
| let parse = |key: &str, config: Option<String>| -> CargoResult<MinPublishAge> { | |
| let Some(config) = config else { | |
| return Ok(MinPublishAge::Unset); | |
| }; | |
| if config == "0" { | |
| return Ok(MinPublishAge::None); | |
| } | |
| let duration = parse_time_span(&config) | |
| .map_err(|e| anyhow::format_err!("invalid value for `{key}`: {e}"))?; | |
| Ok(MinPublishAge::Age(duration, config)) | |
| }; | |
| let registry = gctx.get::<Option<GlobalRegistryConfig>>(["registry"])?; | |
| let global = parse( | |
| "registry.global-min-publish-age", | |
| registry.and_then(|r| r.global_min_publish_age), | |
| )?; | |
| let mut per_registry = HashMap::default(); | |
| if let Some(registries) = | |
| gctx.get::<Option<HashMap<String, RegistryConfig>>>(["registries"])? | |
| { | |
| for (name, config) in registries { | |
| let limit = parse( | |
| &format!("registries.{name}.min-publish-age"), | |
| config.min_publish_age, | |
| )?; | |
| if limit.is_set() { | |
| per_registry.insert(name, limit); | |
| } | |
| } | |
| } | |
| let nothing_configured = !global.is_set() && per_registry.is_empty(); | |
| if nothing_configured { | |
| return Ok(None); | |
| } | |
| Ok(Some(Self { | |
| invocation_time: now.unwrap_or_else(|| gctx.invocation_time()), | |
| global, | |
| per_registry, | |
| })) | |
| } | |
| /// Returns the version's publish-age if it is too new for its registry. | |
| /// | |
| /// `None` means the version is acceptable. | |
| pub fn too_new(&self, summary: &Summary) -> Option<PublishAgeViolation> { | |
| let pubtime = summary.pubtime()?; | |
| let MinPublishAge::Age(min_age, config) = self.min_age(summary.source_id()) else { | |
| return None; | |
| }; | |
| let max_pubtime = jiff::SignedDuration::try_from(*min_age) | |
| .ok() | |
| .and_then(|min_age| self.invocation_time.checked_sub(min_age).ok()); | |
| let age = self.invocation_time.duration_since(pubtime); | |
| let publish_age = || PublishAgeViolation { | |
| age, | |
| config: config.clone(), | |
| }; | |
| match max_pubtime { | |
| Some(max_pubtime) => (pubtime > max_pubtime).then(publish_age), | |
| None => Some(publish_age()), | |
| } | |
| } | |
| /// Resolves the minimum publish age for a given registry source. | |
| /// | |
| /// Priority: | |
| /// | |
| /// 1. `registries.<name>.min-publish-age` | |
| /// 2. `registry.global-min-publish-age` | |
| fn min_age(&self, source_id: SourceId) -> &MinPublishAge { | |
| let name = source_id.alt_registry_key().or_else(|| { | |
| // Our crates.io source ID has two forms: | |
| // One with al_registry_key name and one without. | |
| // SOURCE_ID_CACHE hold only one instance of them, | |
| // so there may be a race. | |
| // We need to fall back if missing one. | |
| source_id.is_crates_io().then_some(CRATES_IO_REGISTRY) | |
| }); | |
| if let Some(min_age) = name | |
| .and_then(|name| self.per_registry.get(name)) | |
| .filter(|min_age| min_age.is_set()) | |
| { | |
| return min_age; | |
| } | |
| &self.global | |
| } | |
| /// A single min-publish-age, if there is one | |
| pub fn common_min_publish_age(&self) -> Option<PublishAgeViolation> { | |
| if !self.per_registry.is_empty() { | |
| return None; | |
| } | |
| if let MinPublishAge::Age(age, config) = &self.global { | |
| jiff::SignedDuration::try_from(*age).ok().and_then(|age| { | |
| Some(PublishAgeViolation { | |
| age, | |
| config: config.clone(), | |
| }) | |
| }) | |
| } else { | |
| None | |
| } | |
| } | |
| } | |
| /// A configured `min-publish-age` value for one scope. | |
| #[derive(Debug, Clone)] | |
| enum MinPublishAge { | |
| /// Key unset. | |
| Unset, | |
| /// No min-publish-age limit at all. | |
| None, | |
| /// An age threshold, with the raw config string for display. | |
| Age(Duration, String), | |
| } | |
| impl MinPublishAge { | |
| /// Whether a value was configured for this scope. | |
| fn is_set(&self) -> bool { | |
| !matches!(self, MinPublishAge::Unset) | |
| } | |
| } | |
| /// A violation of `min-publish-age` config. | |
| #[derive(Debug, Clone, PartialEq)] | |
| pub struct PublishAgeViolation { | |
| /// How long ago the version was published. | |
| age: jiff::SignedDuration, | |
| /// The configured `min-publish-age` it violates | |
| config: String, | |
| } | |
| impl PublishAgeViolation { | |
| /// How long ago the version was published, | |
| /// as a single friendly-spelled unit for display. | |
| pub fn age_label(&self) -> String { | |
| format_age_as_single_unit(self.age) | |
| } | |
| /// The configured `min-publish-age` it violates | |
| pub fn config(&self) -> &str { | |
| &self.config | |
| } | |
| /// A human-readable note describing the violation, | |
| /// like `published 2 days ago, minimum age 7 days`. | |
| pub fn note(&self) -> String { | |
| let age = self.age_label(); | |
| let config = self.config(); | |
| format!("published {age} ago, minimum age {config}",) | |
| } | |
| } | |
| /// Formats an age as a single, friendly-spelled unit, never is multi-unit noise. | |
| fn format_age_as_single_unit(age: jiff::SignedDuration) -> String { | |
| use jiff::Unit; | |
| use jiff::fmt::friendly::Designator; | |
| use jiff::fmt::friendly::Spacing; | |
| use jiff::fmt::friendly::SpanPrinter; | |
| // An age at or ahead of "now" gives a non-positive age. | |
| if age <= jiff::SignedDuration::ZERO { | |
| return "moments".to_string(); | |
| } | |
| let rounded = jiff::Span::try_from(age).and_then(|span| { | |
| let unit = if age >= jiff::SignedDuration::from_hours(48) { | |
| Unit::Day | |
| } else if age >= jiff::SignedDuration::from_hours(1) { | |
| Unit::Hour | |
| } else if age >= jiff::SignedDuration::from_mins(1) { | |
| Unit::Minute | |
| } else { | |
| Unit::Second | |
| }; | |
| let opts = jiff::SpanRound::new() | |
| .largest(unit) | |
| .smallest(unit) | |
| .relative(jiff::SpanRelativeTo::days_are_24_hours()); | |
| span.round(opts) | |
| }); | |
| let printer = SpanPrinter::new() | |
| .designator(Designator::Verbose) | |
| .spacing(Spacing::BetweenUnitsAndDesignators); | |
| match rounded { | |
| Ok(span) => printer.span_to_string(&span).to_string(), | |
| Err(e) => { | |
| tracing::warn!("failed to round `{age}`: {e}"); | |
| format!("{} seconds", age.as_secs()) | |
| } | |
| } | |
| } | |
| #[cfg(test)] | |
| mod test { | |
| use super::*; | |
| use crate::sources::CRATES_IO_INDEX; | |
| use crate::sources::CRATES_IO_REGISTRY; | |
| use crate::util::IntoUrl as _; | |
| use crate::workspace::SourceId; | |
| use std::collections::BTreeMap; | |
| fn pkgid(name: &str, version: &str) -> PackageId { | |
| let src_id = | |
| SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index").unwrap(); | |
| PackageId::try_new(name, version, src_id).unwrap() | |
| } | |
| fn dep(name: &str, version: &str) -> Dependency { | |
| let src_id = | |
| SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index").unwrap(); | |
| Dependency::parse(name, Some(version), src_id).unwrap() | |
| } | |
| fn summ(name: &str, version: &str, msrv: Option<&str>) -> Summary { | |
| let pkg_id = pkgid(name, version); | |
| let features = BTreeMap::new(); | |
| Summary::new( | |
| pkg_id, | |
| Vec::new(), | |
| &features, | |
| None::<&String>, | |
| msrv.map(|m| m.parse().unwrap()), | |
| ) | |
| .unwrap() | |
| } | |
| fn describe(summaries: &Vec<Summary>) -> String { | |
| let strs: Vec<String> = summaries | |
| .iter() | |
| .map(|summary| format!("{}/{}", summary.name(), summary.version())) | |
| .collect(); | |
| strs.join(", ") | |
| } | |
| #[test] | |
| fn test_prefer_package_id() { | |
| let mut vp = VersionPreferences::default(); | |
| vp.prefer_package_id(pkgid("foo", "1.2.3")); | |
| let mut summaries = vec![ | |
| summ("foo", "1.2.4", None), | |
| summ("foo", "1.2.3", None), | |
| summ("foo", "1.1.0", None), | |
| summ("foo", "1.0.9", None), | |
| ]; | |
| vp.version_ordering(VersionOrdering::MaximumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "foo/1.2.3, foo/1.2.4, foo/1.1.0, foo/1.0.9".to_string() | |
| ); | |
| vp.version_ordering(VersionOrdering::MinimumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "foo/1.2.3, foo/1.0.9, foo/1.1.0, foo/1.2.4".to_string() | |
| ); | |
| } | |
| #[test] | |
| fn test_prefer_dependency() { | |
| let mut vp = VersionPreferences::default(); | |
| vp.prefer_dependency(dep("foo", "=1.2.3")); | |
| let mut summaries = vec![ | |
| summ("foo", "1.2.4", None), | |
| summ("foo", "1.2.3", None), | |
| summ("foo", "1.1.0", None), | |
| summ("foo", "1.0.9", None), | |
| ]; | |
| vp.version_ordering(VersionOrdering::MaximumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "foo/1.2.3, foo/1.2.4, foo/1.1.0, foo/1.0.9".to_string() | |
| ); | |
| vp.version_ordering(VersionOrdering::MinimumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "foo/1.2.3, foo/1.0.9, foo/1.1.0, foo/1.2.4".to_string() | |
| ); | |
| } | |
| #[test] | |
| fn test_prefer_both() { | |
| let mut vp = VersionPreferences::default(); | |
| vp.prefer_package_id(pkgid("foo", "1.2.3")); | |
| vp.prefer_dependency(dep("foo", "=1.1.0")); | |
| let mut summaries = vec![ | |
| summ("foo", "1.2.4", None), | |
| summ("foo", "1.2.3", None), | |
| summ("foo", "1.1.0", None), | |
| summ("foo", "1.0.9", None), | |
| ]; | |
| vp.version_ordering(VersionOrdering::MaximumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "foo/1.2.3, foo/1.1.0, foo/1.2.4, foo/1.0.9".to_string() | |
| ); | |
| vp.version_ordering(VersionOrdering::MinimumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "foo/1.1.0, foo/1.2.3, foo/1.0.9, foo/1.2.4".to_string() | |
| ); | |
| } | |
| #[test] | |
| fn test_single_rust_version() { | |
| let mut vp = VersionPreferences::default(); | |
| vp.rust_versions(vec!["1.50".parse().unwrap()]); | |
| let mut summaries = vec![ | |
| summ("foo", "1.2.4", None), | |
| summ("foo", "1.2.3", Some("1.60")), | |
| summ("foo", "1.2.2", None), | |
| summ("foo", "1.2.1", Some("1.50")), | |
| summ("foo", "1.2.0", None), | |
| summ("foo", "1.1.0", Some("1.40")), | |
| summ("foo", "1.0.9", None), | |
| ]; | |
| vp.version_ordering(VersionOrdering::MaximumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "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" | |
| .to_string() | |
| ); | |
| vp.version_ordering(VersionOrdering::MinimumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "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" | |
| .to_string() | |
| ); | |
| } | |
| #[test] | |
| fn test_multiple_rust_versions() { | |
| let mut vp = VersionPreferences::default(); | |
| vp.rust_versions(vec!["1.45".parse().unwrap(), "1.55".parse().unwrap()]); | |
| let mut summaries = vec![ | |
| summ("foo", "1.2.4", None), | |
| summ("foo", "1.2.3", Some("1.60")), | |
| summ("foo", "1.2.2", None), | |
| summ("foo", "1.2.1", Some("1.50")), | |
| summ("foo", "1.2.0", None), | |
| summ("foo", "1.1.0", Some("1.40")), | |
| summ("foo", "1.0.9", None), | |
| ]; | |
| vp.version_ordering(VersionOrdering::MaximumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "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" | |
| .to_string() | |
| ); | |
| vp.version_ordering(VersionOrdering::MinimumVersionsFirst); | |
| vp.sort_summaries(&mut summaries, None); | |
| assert_eq!( | |
| describe(&summaries), | |
| "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" | |
| .to_string() | |
| ); | |
| } | |
| #[test] | |
| fn test_empty_summaries() { | |
| let vp = VersionPreferences::default(); | |
| let mut summaries = vec![]; | |
| vp.sort_summaries(&mut summaries, Some(VersionOrdering::MaximumVersionsFirst)); | |
| assert_eq!(summaries, vec![]); | |
| } | |
| const NOW: &str = "2006-08-08T00:00:00Z"; | |
| fn age(raw: &str) -> MinPublishAge { | |
| MinPublishAge::Age(parse_time_span(raw).unwrap(), raw.to_string()) | |
| } | |
| fn hours(n: i64) -> jiff::SignedDuration { | |
| jiff::SignedDuration::from_hours(n) | |
| } | |
| fn policy(global: MinPublishAge, per_registry: &[(&str, MinPublishAge)]) -> PublishAgePolicy { | |
| PublishAgePolicy { | |
| invocation_time: NOW.parse().unwrap(), | |
| global, | |
| per_registry: per_registry | |
| .iter() | |
| .map(|(name, age)| (name.to_string(), age.clone())) | |
| .collect(), | |
| } | |
| } | |
| fn crates_io_source() -> SourceId { | |
| let url = CRATES_IO_INDEX.into_url().unwrap(); | |
| SourceId::for_alt_registry(&url, CRATES_IO_REGISTRY).unwrap() | |
| } | |
| fn alt_source() -> SourceId { | |
| let url = "https://example.com/index".into_url().unwrap(); | |
| SourceId::for_alt_registry(&url, "alt").unwrap() | |
| } | |
| /// Gets a summary on `source`, published `age` before `NOW`. | |
| /// If age is negative, it means it is published in the future. | |
| fn published(source: SourceId, age: jiff::SignedDuration) -> Summary { | |
| let pkg_id = PackageId::try_new("foo", "1.0.0", source).unwrap(); | |
| let mut summary = | |
| Summary::new(pkg_id, Vec::new(), &BTreeMap::new(), None::<&String>, None).unwrap(); | |
| let now: jiff::Timestamp = NOW.parse().unwrap(); | |
| summary.set_pubtime(now - age); | |
| summary | |
| } | |
| #[test] | |
| fn publish_age_reports_exact_age() { | |
| let p = policy(age("7 days"), &[]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(50))); | |
| assert_eq!( | |
| violation, | |
| Some(PublishAgeViolation { | |
| age: hours(50), | |
| config: "7 days".to_string(), | |
| }) | |
| ); | |
| } | |
| #[test] | |
| fn publish_age_older_than_threshold_is_acceptable() { | |
| let p = policy(age("7 days"), &[]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(10 * 24))); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_at_threshold_boundary_is_acceptable() { | |
| let p = policy(age("7 days"), &[]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(7 * 24))); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_just_inside_threshold_is_too_new() { | |
| let p = policy(age("7 days"), &[]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(7 * 24 - 1))); | |
| assert_eq!( | |
| violation, | |
| Some(PublishAgeViolation { | |
| age: hours(7 * 24 - 1), | |
| config: "7 days".to_string(), | |
| }) | |
| ); | |
| } | |
| #[test] | |
| fn publish_age_per_registry_overrides_global() { | |
| let p = policy(age("30 days"), &[("alt", age("1 day"))]); | |
| let violation = p.too_new(&published(alt_source(), hours(2 * 24))); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_crates_io_scope_excludes_alt_registry() { | |
| let p = policy(age("1 day"), &[(CRATES_IO_REGISTRY, age("30 days"))]); | |
| let crates_io = p.too_new(&published(crates_io_source(), hours(2 * 24))); | |
| let alt = p.too_new(&published(alt_source(), hours(2 * 24))); | |
| assert_eq!( | |
| crates_io, | |
| Some(PublishAgeViolation { | |
| age: hours(2 * 24), | |
| config: "30 days".to_string(), | |
| }) | |
| ); | |
| assert_eq!(alt, None); | |
| } | |
| #[test] | |
| fn publish_age_alt_registry_falls_through_to_global() { | |
| let p = policy(age("7 days"), &[]); | |
| let violation = p.too_new(&published(alt_source(), hours(2 * 24))); | |
| assert_eq!( | |
| violation, | |
| Some(PublishAgeViolation { | |
| age: hours(2 * 24), | |
| config: "7 days".to_string(), | |
| }) | |
| ); | |
| } | |
| #[test] | |
| fn publish_age_per_registry_too_new() { | |
| let p = policy(MinPublishAge::Unset, &[("alt", age("7 days"))]); | |
| let violation = p.too_new(&published(alt_source(), hours(2 * 24))); | |
| assert_eq!( | |
| violation, | |
| Some(PublishAgeViolation { | |
| age: hours(2 * 24), | |
| config: "7 days".to_string(), | |
| }) | |
| ); | |
| } | |
| #[test] | |
| fn publish_age_per_registry_zero_overrides_global() { | |
| let p = policy(age("30 days"), &[("alt", MinPublishAge::None)]); | |
| let violation = p.too_new(&published(alt_source(), hours(0))); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_no_applicable_scope_is_acceptable() { | |
| let p = policy(MinPublishAge::Unset, &[(CRATES_IO_REGISTRY, age("7 days"))]); | |
| let violation = p.too_new(&published(alt_source(), hours(0))); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_zero_disables_threshold() { | |
| let p = policy(MinPublishAge::None, &[]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(0))); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_zero_stops_scope_fallthrough() { | |
| let p = policy(age("30 days"), &[(CRATES_IO_REGISTRY, MinPublishAge::None)]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(0))); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_missing_pubtime_is_acceptable() { | |
| let p = policy(age("7 days"), &[]); | |
| let pkg_id = PackageId::try_new("foo", "1.0.0", crates_io_source()).unwrap(); | |
| let summary = | |
| Summary::new(pkg_id, Vec::new(), &BTreeMap::new(), None::<&String>, None).unwrap(); | |
| let violation = p.too_new(&summary); | |
| assert_eq!(violation, None); | |
| } | |
| #[test] | |
| fn publish_age_future_pubtime_is_too_new() { | |
| let p = policy(age("7 days"), &[]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(-24))); | |
| assert_eq!( | |
| violation, | |
| Some(PublishAgeViolation { | |
| age: hours(-24), | |
| config: "7 days".to_string(), | |
| }) | |
| ); | |
| } | |
| #[test] | |
| fn publish_age_out_of_range_threshold_is_too_new() { | |
| // u64::MAX | |
| let p = policy(age("18446744073709551615 seconds"), &[]); | |
| let violation = p.too_new(&published(crates_io_source(), hours(24))); | |
| assert_eq!( | |
| violation, | |
| Some(PublishAgeViolation { | |
| age: hours(24), | |
| config: "18446744073709551615 seconds".to_string(), | |
| }) | |
| ); | |
| } | |
| #[track_caller] | |
| fn assert_age(secs: i64, expected: &str) { | |
| assert_eq!( | |
| format_age_as_single_unit(jiff::SignedDuration::from_secs(secs)), | |
| expected | |
| ); | |
| } | |
| const MIN: i64 = 60; | |
| const HOUR: i64 = 60 * MIN; | |
| const DAY: i64 = 24 * HOUR; | |
| #[test] | |
| fn rounds_to_a_single_unit() { | |
| // `>= 2 days` rounds to the nearest day. | |
| assert_age(2 * DAY, "2 days"); | |
| assert_age(2 * DAY + 8 * HOUR + 23 * MIN, "2 days"); | |
| assert_age(2 * DAY + 13 * HOUR, "3 days"); | |
| assert_age(540 * DAY, "540 days"); | |
| // `1 hour ..< 2 days` rounds to the nearest hour. | |
| assert_age(47 * HOUR, "47 hours"); | |
| assert_age(24 * HOUR, "24 hours"); | |
| assert_age(11 * HOUR + 40 * MIN, "12 hours"); | |
| assert_age(11 * HOUR + 20 * MIN, "11 hours"); | |
| assert_age(HOUR, "1 hour"); | |
| // `1 minute ..< 1 hour` rounds to the nearest minute. | |
| assert_age(40 * MIN, "40 minutes"); | |
| assert_age(MIN, "1 minute"); | |
| // `< 1 minute` rounds to the nearest second. | |
| assert_age(40, "40 seconds"); | |
| assert_age(1, "1 second"); | |
| // ahead of "now" (clock drift) | |
| assert_age(0, "moments"); | |
| assert_age(-20, "moments"); | |
| assert_age(-2 * DAY, "moments"); | |
| } | |
| } |