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