Skip to content

Commit 8e15e85

Browse files
committed
Handle negative spans
1 parent 4520326 commit 8e15e85

2 files changed

Lines changed: 112 additions & 15 deletions

File tree

crates/uv-resolver/src/exclude_newer.rs

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -194,20 +194,14 @@ impl From<Timestamp> for ExcludeNewerValue {
194194
/// - `[-+]?\s*[0-9]+\s*[A-Za-z]` → friendly duration (e.g., `2 weeks`, `-30 days`)
195195
/// - `[-+]?[0-9]{4}-` → date/timestamp (e.g., `2024-01-01`)
196196
/// - Otherwise → generic error with examples
197-
fn format_exclude_newer_error(
198-
input: &str,
199-
date_err: jiff::Error,
200-
span_err: jiff::Error,
201-
) -> String {
197+
fn format_exclude_newer_error(input: &str, date_err: jiff::Error, span_err: jiff::Error) -> String {
202198
let trimmed = input.trim();
203199

204200
// Check for ISO 8601 duration: [-+]?[Pp]
205201
// e.g., "P2W", "+P1D", "-P30D"
206202
let after_sign = trimmed.trim_start_matches(['+', '-']);
207203
if after_sign.starts_with('P') || after_sign.starts_with('p') {
208-
return format!(
209-
"`{input}` could not be parsed as an ISO 8601 duration: {span_err}"
210-
);
204+
return format!("`{input}` could not be parsed as an ISO 8601 duration: {span_err}");
211205
}
212206

213207
// Check for friendly duration: [-+]?\s*[0-9]+\s*[A-Za-z]
@@ -227,9 +221,7 @@ fn format_exclude_newer_error(
227221
}
228222
// Check if next character is a letter (unit designator)
229223
if chars.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
230-
return format!(
231-
"`{input}` could not be parsed as a relative duration: {span_err}"
232-
);
224+
return format!("`{input}` could not be parsed as a relative duration: {span_err}");
233225
}
234226
}
235227

@@ -243,9 +235,7 @@ fn format_exclude_newer_error(
243235
&& chars.next().is_some_and(|c| c == '-');
244236

245237
if looks_like_date {
246-
return format!(
247-
"`{input}` could not be parsed as a valid date: {date_err}"
248-
);
238+
return format!("`{input}` could not be parsed as a valid date: {date_err}");
249239
}
250240

251241
// Fallback: generic error showing both possibilities
@@ -327,7 +317,10 @@ impl FromStr for ExcludeNewerValue {
327317

328318
// We're using a UTC timezone so there are no transitions (e.g., DST) and days are
329319
// always 24 hours. This means that we can also allow weeks as a unit.
330-
let cutoff = now.checked_sub(span).map_err(|err| {
320+
//
321+
// Note we use `span.abs()` so `1 day ago` has the same effect as `1 day` instead
322+
// of resulting in a future date.
323+
let cutoff = now.checked_sub(span.abs()).map_err(|err| {
331324
format!("Duration `{input}` is too large to subtract from current time: {err}")
332325
})?;
333326

@@ -623,4 +616,5 @@ mod tests {
623616
assert_eq!(entry.package.as_ref(), "requests");
624617
// Just verify it parsed without error - the timestamp will be relative to now
625618
}
619+
626620
}

crates/uv/tests/it/lock_exclude_newer_relative.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,109 @@ fn lock_exclude_newer_mixed_relative_global_absolute_package() -> Result<()> {
11371137
Ok(())
11381138
}
11391139

1140+
/// Test that negative durations produce the same timestamp as positive durations.
1141+
/// This ensures that `span.abs()` is applied correctly, so "-1 day" and "1 day" both
1142+
/// result in a cutoff 1 day in the past (not 1 day in the future for negative).
1143+
#[test]
1144+
fn lock_exclude_newer_negative_duration_same_as_positive() -> Result<()> {
1145+
let context = TestContext::new("3.12");
1146+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
1147+
pyproject_toml.write_str(
1148+
r#"
1149+
[project]
1150+
name = "project"
1151+
version = "0.1.0"
1152+
requires-python = ">=3.12"
1153+
dependencies = ["iniconfig"]
1154+
"#,
1155+
)?;
1156+
1157+
// Lock with positive duration "7 days"
1158+
uv_snapshot!(context.filters(), context
1159+
.lock()
1160+
.env_remove(EnvVars::UV_EXCLUDE_NEWER)
1161+
.env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2025-11-21T12:00:00Z")
1162+
.arg("--exclude-newer")
1163+
.arg("7 days"), @r###"
1164+
success: true
1165+
exit_code: 0
1166+
----- stdout -----
1167+
1168+
----- stderr -----
1169+
Resolved 2 packages in [TIME]
1170+
"###);
1171+
1172+
let lock_positive = context.read("uv.lock");
1173+
let timestamp_positive = lock_positive
1174+
.lines()
1175+
.find(|line| line.starts_with("exclude-newer = "))
1176+
.expect("Should find exclude-newer line");
1177+
1178+
let _ = fs_err::remove_file(context.temp_dir.child("uv.lock"));
1179+
1180+
// Lock with negative ISO 8601 duration "-P7D" (should produce same timestamp)
1181+
// Note: We use --exclude-newer=-P7D to avoid the dash being interpreted as a flag
1182+
uv_snapshot!(context.filters(), context
1183+
.lock()
1184+
.env_remove(EnvVars::UV_EXCLUDE_NEWER)
1185+
.env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2025-11-21T12:00:00Z")
1186+
.arg("--exclude-newer=-P7D"), @r###"
1187+
success: true
1188+
exit_code: 0
1189+
----- stdout -----
1190+
1191+
----- stderr -----
1192+
Resolved 2 packages in [TIME]
1193+
"###);
1194+
1195+
let lock_negative_iso = context.read("uv.lock");
1196+
let timestamp_negative_iso = lock_negative_iso
1197+
.lines()
1198+
.find(|line| line.starts_with("exclude-newer = "))
1199+
.expect("Should find exclude-newer line");
1200+
1201+
let _ = fs_err::remove_file(context.temp_dir.child("uv.lock"));
1202+
1203+
// Lock with "7 days ago" friendly format (should also produce same timestamp)
1204+
uv_snapshot!(context.filters(), context
1205+
.lock()
1206+
.env_remove(EnvVars::UV_EXCLUDE_NEWER)
1207+
.env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2025-11-21T12:00:00Z")
1208+
.arg("--exclude-newer")
1209+
.arg("7 days ago"), @r###"
1210+
success: true
1211+
exit_code: 0
1212+
----- stdout -----
1213+
1214+
----- stderr -----
1215+
Resolved 2 packages in [TIME]
1216+
"###);
1217+
1218+
let lock_ago = context.read("uv.lock");
1219+
let timestamp_ago = lock_ago
1220+
.lines()
1221+
.find(|line| line.starts_with("exclude-newer = "))
1222+
.expect("Should find exclude-newer line");
1223+
1224+
// All three should produce the same cutoff timestamp (7 days before 2025-11-21T12:00:00Z)
1225+
assert_eq!(
1226+
timestamp_positive, timestamp_negative_iso,
1227+
"Negative ISO duration should produce the same timestamp as positive duration"
1228+
);
1229+
assert_eq!(
1230+
timestamp_positive, timestamp_ago,
1231+
"'7 days ago' should produce the same timestamp as '7 days'"
1232+
);
1233+
1234+
// Verify the actual timestamp is correct (2025-11-14T12:00:00Z = 7 days before 2025-11-21T12:00:00Z)
1235+
assert!(
1236+
timestamp_positive.contains("2025-11-14T12:00:00Z"),
1237+
"Expected timestamp to be 2025-11-14T12:00:00Z, got: {timestamp_positive}"
1238+
);
1239+
1240+
Ok(())
1241+
}
1242+
11401243
/// Changing the span in pyproject.toml invalidates the lockfile.
11411244
#[test]
11421245
fn lock_exclude_newer_span_change_invalidates() -> Result<()> {

0 commit comments

Comments
 (0)