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