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