Skip to content
5 changes: 4 additions & 1 deletion src/bin/cargo/commands/clean.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use crate::command_prelude::*;
use crate::util::cache_lock::CacheLockMode;
use crate::util::time_span::parse_time_span;

use cargo::core::gc::Gc;
use cargo::core::gc::{GcOpts, parse_human_size, parse_time_span};
use cargo::core::gc::GcOpts;
use cargo::core::gc::parse_human_size;
use cargo::core::global_cache_tracker::GlobalCacheTracker;
use cargo::ops::CleanContext;
use cargo::ops::{self, CleanOptions};
Expand Down
2 changes: 1 addition & 1 deletion src/cargo/core/compiler/future_incompat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ fn get_updates(ws: &Workspace<'_>, package_ids: &BTreeSet<PackageId>) -> Option<
let sources: HashMap<_, _> = source_ids
.into_iter()
.filter_map(|sid| {
let source = map.load(sid, &HashSet::new()).ok()?;
let source = map.load(sid).ok()?;
Some((sid, source))
})
.collect();
Expand Down
2 changes: 2 additions & 0 deletions src/cargo/core/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,7 @@ unstable_cli_options!(
gitoxide: Option<GitoxideFeatures> = ("Use gitoxide for the given git interactions, or all of them if no argument is given"),
host_config: bool = ("Enable the `[host]` section in the .cargo/config.toml file"),
json_target_spec: bool = ("Enable `.json` target spec files"),
min_publish_age: bool = ("Enable the `min-publish-age` configuration for dependency version age filtering"),
minimal_versions: bool = ("Resolve minimal dependency versions instead of maximum"),
msrv_policy: bool = ("Enable rust-version aware policy within cargo"),
mtime_on_use: bool = ("Configure Cargo to update the mtime of used files"),
Expand Down Expand Up @@ -1431,6 +1432,7 @@ impl CliUnstable {
}
"host-config" => self.host_config = parse_empty(k, v)?,
"json-target-spec" => self.json_target_spec = parse_empty(k, v)?,
"min-publish-age" => self.min_publish_age = parse_empty(k, v)?,
"next-lockfile-bump" => self.next_lockfile_bump = parse_empty(k, v)?,
"minimal-versions" => self.minimal_versions = parse_empty(k, v)?,
"msrv-policy" => self.msrv_policy = parse_empty(k, v)?,
Expand Down
59 changes: 1 addition & 58 deletions src/cargo/core/gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use crate::core::global_cache_tracker::{self, GlobalCacheTracker};
use crate::ops::CleanContext;
use crate::util::cache_lock::{CacheLock, CacheLockMode};
use crate::util::time_span::maybe_parse_time_span;
use crate::{CargoResult, GlobalContext};
use anyhow::{Context as _, format_err};
use serde::Deserialize;
Expand Down Expand Up @@ -372,41 +373,6 @@ fn parse_time_span_for_config(config_name: &str, span: &str) -> CargoResult<Dura
})
}

/// Parses a time span string.
///
/// Returns None if the value is not valid. See [`parse_time_span`] if you
/// need a variant that generates an error message.
fn maybe_parse_time_span(span: &str) -> Option<Duration> {
let Some(right_i) = span.find(|c: char| !c.is_ascii_digit()) else {
return None;
};
let (left, mut right) = span.split_at(right_i);
if right.starts_with(' ') {
right = &right[1..];
}
let count: u64 = left.parse().ok()?;
let factor = match right {
"second" | "seconds" => 1,
"minute" | "minutes" => 60,
"hour" | "hours" => 60 * 60,
"day" | "days" => 24 * 60 * 60,
"week" | "weeks" => 7 * 24 * 60 * 60,
"month" | "months" => 2_629_746, // average is 30.436875 days
_ => return None,
};
Some(Duration::from_secs(factor * count))
}

/// Parses a time span string.
pub fn parse_time_span(span: &str) -> CargoResult<Duration> {
maybe_parse_time_span(span).ok_or_else(|| {
format_err!(
"expected a value of the form \
\"N seconds/minutes/days/weeks/months\", got: {span:?}"
)
})
}

/// Parses a file size using metric or IEC units.
pub fn parse_human_size(input: &str) -> CargoResult<u64> {
let re = regex::Regex::new(r"(?i)^([0-9]+(\.[0-9])?) ?(b|kb|mb|gb|kib|mib|gib)?$").unwrap();
Expand Down Expand Up @@ -445,36 +411,13 @@ mod tests {
#[test]
fn time_spans() {
let d = |x| Some(Duration::from_secs(x));
assert_eq!(maybe_parse_time_span("0 seconds"), d(0));
assert_eq!(maybe_parse_time_span("1second"), d(1));
assert_eq!(maybe_parse_time_span("23 seconds"), d(23));
assert_eq!(maybe_parse_time_span("5 minutes"), d(60 * 5));
assert_eq!(maybe_parse_time_span("2 hours"), d(60 * 60 * 2));
assert_eq!(maybe_parse_time_span("1 day"), d(60 * 60 * 24));
assert_eq!(maybe_parse_time_span("2 weeks"), d(60 * 60 * 24 * 14));
assert_eq!(maybe_parse_time_span("6 months"), d(2_629_746 * 6));

assert_eq!(parse_frequency("5 seconds").unwrap(), d(5));
assert_eq!(parse_frequency("always").unwrap(), d(0));
assert_eq!(parse_frequency("never").unwrap(), None);
}

#[test]
fn time_span_errors() {
assert_eq!(maybe_parse_time_span(""), None);
assert_eq!(maybe_parse_time_span("1"), None);
assert_eq!(maybe_parse_time_span("second"), None);
assert_eq!(maybe_parse_time_span("+2 seconds"), None);
assert_eq!(maybe_parse_time_span("day"), None);
assert_eq!(maybe_parse_time_span("-1 days"), None);
assert_eq!(maybe_parse_time_span("1.5 days"), None);
assert_eq!(maybe_parse_time_span("1 dayz"), None);
assert_eq!(maybe_parse_time_span("always"), None);
assert_eq!(maybe_parse_time_span("never"), None);
assert_eq!(maybe_parse_time_span("1 day "), None);
assert_eq!(maybe_parse_time_span(" 1 day"), None);
assert_eq!(maybe_parse_time_span("1 second"), None);

let e =
parse_time_span_for_config("cache.global-clean.max-src-age", "-1 days").unwrap_err();
assert_eq!(
Expand Down
26 changes: 25 additions & 1 deletion src/cargo/core/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ pub struct PackageRegistry<'gctx> {
locked: LockedMap,
/// Packages allowed to be used, even if they are yanked.
yanked_whitelist: RefCell<HashSet<PackageId>>,
/// Packages allowed to be used, even if they are pubtime-incompatible.
pubtime_allowlist: RefCell<HashSet<PackageId>>,
source_config: SourceConfigMap<'gctx>,

/// Patches registered during calls to [`PackageRegistry::patch`].
Expand Down Expand Up @@ -204,6 +206,7 @@ impl<'gctx> PackageRegistry<'gctx> {
source_config,
locked: HashMap::new(),
yanked_whitelist: RefCell::new(HashSet::new()),
pubtime_allowlist: RefCell::new(HashSet::new()),
patches: HashMap::new(),
patches_locked: false,
patches_available: HashMap::new(),
Expand Down Expand Up @@ -293,6 +296,15 @@ impl<'gctx> PackageRegistry<'gctx> {
self.yanked_whitelist.borrow_mut().extend(pkgs);
}

/// Allows a group of packages to be available to query even if they are pubtime-incompatible
pub fn add_to_pubtime_allowlist(&self, iter: impl Iterator<Item = PackageId>) {
let pkgs = iter.collect::<Vec<_>>();
for (_, source) in self.sources.borrow().iter() {
source.add_to_pubtime_allowlist(&pkgs);
}
self.pubtime_allowlist.borrow_mut().extend(pkgs);
}

/// remove all residual state from previous lock files.
pub fn clear_lock(&mut self) {
trace!("clear_lock");
Expand Down Expand Up @@ -533,10 +545,22 @@ impl<'gctx> PackageRegistry<'gctx> {
debug!("loading source {}", source_id);
let source = self
.source_config
.load(source_id, &self.yanked_whitelist.borrow())
.load(source_id)
.with_context(|| format!("unable to update {}", source_id))?;
assert_eq!(source.source_id(), source_id);

let yanked_whitelist = self.yanked_whitelist.borrow();
if !yanked_whitelist.is_empty() {
let pkgs: Vec<_> = yanked_whitelist.iter().copied().collect();
source.add_to_yanked_whitelist(&pkgs);
}

let pubtime_allowlist = self.pubtime_allowlist.borrow();
if !pubtime_allowlist.is_empty() {
let pkgs: Vec<_> = pubtime_allowlist.iter().copied().collect();
source.add_to_pubtime_allowlist(&pkgs);
}

if kind == Kind::Override {
self.overrides.borrow_mut().push(source_id);
}
Expand Down
20 changes: 20 additions & 0 deletions src/cargo/core/resolver/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,26 @@ pub(super) fn activation_error(
summary.version()
);
}
IndexSummary::TooNew(summary, age) => {
let opts = jiff::SpanRound::new()
.largest(jiff::Unit::Day)
.smallest(jiff::Unit::Minute)
.relative(jiff::SpanRelativeTo::days_are_24_hours());
Comment on lines +294 to +298

@weihanglo weihanglo May 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version 1.99.0 is too new (published 2d ago) message currenly show the time span in always down to minute, and up to day precise. It is annoying that you would get something like 2d 8h 23m long. We might want to round it more for better UX. I personally would leave it for follow-up.

View changes since the review

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@clouatre commented in #17012 (review):

The largest=Day, smallest=Minute rounding produces output like published 2d 8h 23m ago. Suggest simplifying to a single unit based on magnitude: >= 2 days rounds to nearest day (published 3 days ago), < 2 days rounds to nearest hour (published 11 hours ago). The sub-day precision is noise at the day scale and the user cannot act on the minutes component.

For context: we currently enforce this in CI with a bash script that diffs Cargo.lock against the base branch and calls the crates.io API to check publish timestamps on net-new crates. This feature would replace that entirely. The error message is the main user-facing surface, so getting the formatting right matters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it here so it is better to discuss in a thread.

match age.round(opts) {
Ok(age) => {
let _ = writeln!(
&mut msg,
" version {} is too new (published {age:#} ago)",
summary.version(),
);
}
Err(e) => {
tracing::warn!("failed to round `{age}`: {e}");
let _ =
writeln!(&mut msg, " version {} is too new", summary.version());
}
}
}
}
}
} else if let Some(candidates) = alt_versions(registry, dep) {
Expand Down
23 changes: 7 additions & 16 deletions src/cargo/core/source_id.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::core::GitReference;
use crate::core::PackageId;
use crate::core::SourceKind;
use crate::sources::registry::CRATES_IO_HTTP_INDEX;
use crate::sources::source::Source;
Expand Down Expand Up @@ -389,12 +388,9 @@ impl SourceId {

/// Creates an implementation of `Source` corresponding to this ID.
///
/// * `yanked_whitelist` --- Packages allowed to be used, even if they are yanked.
pub fn load<'a>(
self,
gctx: &'a GlobalContext,
yanked_whitelist: &HashSet<PackageId>,
) -> CargoResult<Box<dyn Source + 'a>> {
/// To allow yanked packages through queries,
/// call [`Source::add_to_yanked_whitelist`] on the returned source.
pub fn load<'a>(self, gctx: &'a GlobalContext) -> CargoResult<Box<dyn Source + 'a>> {
trace!("loading SourceId; {}", self);
match self.inner.kind {
SourceKind::Git(..) => Ok(Box::new(GitSource::new(self, gctx)?)),
Expand All @@ -409,21 +405,16 @@ impl SourceId {
}
Ok(Box::new(PathSource::new(&path, self, gctx)))
}
SourceKind::Registry | SourceKind::SparseRegistry => Ok(Box::new(
RegistrySource::remote(self, yanked_whitelist, gctx)?,
)),
SourceKind::Registry | SourceKind::SparseRegistry => {
Ok(Box::new(RegistrySource::remote(self, gctx)?))
}
SourceKind::LocalRegistry => {
let path = self
.inner
.url
.to_file_path()
.expect("path sources cannot be remote");
Ok(Box::new(RegistrySource::local(
self,
&path,
yanked_whitelist,
gctx,
)))
Ok(Box::new(RegistrySource::local(self, &path, gctx)))
}
SourceKind::Directory => {
let path = self
Expand Down
41 changes: 41 additions & 0 deletions src/cargo/core/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,15 @@ impl<'gctx> Workspace<'gctx> {
.warn("ignoring `resolver.feature-unification` without `-Zfeature-unification`")?;
};

if !self.gctx().cli_unstable().min_publish_age {
if config.incompatible_publish_age.is_some() {
self.gctx().shell().warn(
"ignoring `resolver.incompatible-publish-age` without `-Zmin-publish-age`",
)?;
}
warn_unused_min_publish_age(self.gctx())?;
}

if let Some(lockfile_path) = config.lockfile_path {
// Reserve the ability to add templates in the future.
let replacements: [(&str, &str); 0] = [];
Expand Down Expand Up @@ -2068,6 +2077,38 @@ impl WorkspaceRootConfig {
}
}

fn warn_unused_min_publish_age(gctx: &GlobalContext) -> CargoResult<()> {

@weihanglo weihanglo May 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We warn all unused min-publish-age all at once, regardless of the precedence rule.

View changes since the review

if gctx
.get::<Option<String>>("registry.global-min-publish-age")?
.is_some()
{
gctx.shell()
.warn("ignoring `registry.global-min-publish-age` without `-Zmin-publish-age`")?;
}

if gctx
.get::<Option<String>>("registry.min-publish-age")?
.is_some()
{
gctx.shell()
.warn("ignoring `registry.min-publish-age` without `-Zmin-publish-age`")?;
}

if let Some(context::ConfigValue::Table(registries, _)) = gctx.values()?.get("registries") {
for (name, val) in registries {
if let context::ConfigValue::Table(val, _) = val {
if val.contains_key("min-publish-age") {
gctx.shell().warn(format!(
"ignoring `registries.{name}.min-publish-age` without `-Zmin-publish-age`"
))?;
}
}
}
}

Ok(())
}

pub fn resolve_relative_path(
label: &str,
old_root: &Path,
Expand Down
6 changes: 4 additions & 2 deletions src/cargo/ops/cargo_install.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{env, fmt, fs};
Expand Down Expand Up @@ -184,7 +186,7 @@ impl<'gctx> InstallablePackage<'gctx> {
current_rust_version,
)?
} else if let Some(dep) = dep {
let mut source = map.load(source_id, &HashSet::new())?;
let mut source = map.load(source_id)?;
if let Ok(Some(pkg)) = installed_exact_package(
dep.clone(),
&mut *source,
Expand Down
10 changes: 4 additions & 6 deletions src/cargo/ops/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ fn registry<'gctx>(
auth::cache_token_from_commandline(gctx, &source_ids.original, token);
}

let src = RegistrySource::remote(source_ids.replacement, &HashSet::new(), gctx)?;
let src = RegistrySource::remote(source_ids.replacement, gctx)?;
let cfg = {
let _lock = gctx.acquire_package_cache_lock(CacheLockMode::DownloadExclusive)?;
// Only update the index if `force_update` is set.
Expand Down Expand Up @@ -265,11 +265,9 @@ fn get_replacement_source_ids(
sid: SourceId,
) -> CargoResult<(SourceId, SourceId)> {
let builtin_replacement_sid = SourceConfigMap::empty(gctx)?
.load(sid, &HashSet::new())?
.replaced_source_id();
let replacement_sid = SourceConfigMap::new(gctx)?
.load(sid, &HashSet::new())?
.load(sid)?
.replaced_source_id();
let replacement_sid = SourceConfigMap::new(gctx)?.load(sid)?.replaced_source_id();
Ok((builtin_replacement_sid, replacement_sid))
}

Expand All @@ -279,7 +277,7 @@ fn is_replacement_for_package_source(
package_source_id: SourceId,
) -> CargoResult<bool> {
let pkg_source_replacement_sid = SourceConfigMap::new(gctx)?
.load(package_source_id, &HashSet::new())?
.load(package_source_id)?
.replaced_source_id();
Ok(pkg_source_replacement_sid == sid)
}
Expand Down
3 changes: 1 addition & 2 deletions src/cargo/ops/registry/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs::File;
use std::io::Seek;
use std::io::SeekFrom;
Expand Down Expand Up @@ -390,7 +389,7 @@ fn wait_for_any_publish_confirmation(
pkgs: &BTreeSet<PackageId>,
timeout: Duration,
) -> CargoResult<BTreeSet<PackageId>> {
let mut source = SourceConfigMap::empty(gctx)?.load(registry_src, &HashSet::new())?;
let mut source = SourceConfigMap::empty(gctx)?.load(registry_src)?;
// Disable the source's built-in progress bars. Repeatedly showing a bunch
// of independent progress bars can be a little confusing. There is an
// overall progress bar managed here.
Expand Down
1 change: 1 addition & 0 deletions src/cargo/ops/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ fn register_previous_locks(
// in (as we didn't accidentally lock it to an old version).
let mut avoid_locking = HashSet::new();
registry.add_to_yanked_whitelist(resolve.iter().filter(keep));
registry.add_to_pubtime_allowlist(resolve.iter());
for node in resolve.iter() {
if !keep(&node) {
add_deps(resolve, node, &mut avoid_locking);
Expand Down
Loading
Loading