1use anyhow::{Context, Result, ensure};
4use pep440_rs::{Operator, Version, VersionSpecifier};
5use pep508_rs::{Requirement, VersionOrUrl};
6use regex::Regex;
7use std::sync::LazyLock;
8use toml_edit::{DocumentMut, Item, Value};
9
10#[derive(Clone, Debug)]
11pub struct Dependency {
12 pub requirement: String,
13 pub section: Vec<String>,
14 pub index: usize,
15}
16
17#[derive(Clone, Debug)]
18pub struct Update {
19 pub dependency: Dependency,
20 pub replacement: String,
21 pub version: Version,
22 pub release_link: Option<String>,
23 pub project_link: Option<String>,
24}
25
26pub fn dependencies(document: &DocumentMut) -> Vec<Dependency> {
28 let mut arrays = Vec::new();
29 if let Some(project) = document.get("project") {
30 if let Some(array) = project.get("dependencies").and_then(Item::as_array) {
31 arrays.push((vec!["project".into(), "dependencies".into()], array));
32 }
33 if let Some(extras) = project
34 .get("optional-dependencies")
35 .and_then(Item::as_table_like)
36 {
37 for (name, item) in extras.iter() {
38 if let Some(array) = item.as_array() {
39 arrays.push((
40 vec![
41 "project".into(),
42 "optional-dependencies".into(),
43 name.into(),
44 ],
45 array,
46 ));
47 }
48 }
49 }
50 }
51 if let Some(groups) = document
52 .get("dependency-groups")
53 .and_then(Item::as_table_like)
54 {
55 for (name, item) in groups.iter() {
56 if let Some(array) = item.as_array() {
57 arrays.push((vec!["dependency-groups".into(), name.into()], array));
58 }
59 }
60 }
61 arrays
62 .into_iter()
63 .flat_map(|(section, array)| {
64 array.iter().enumerate().filter_map(move |(index, item)| {
65 item.as_str().map(|requirement| Dependency {
66 requirement: requirement.into(),
67 section: section.clone(),
68 index,
69 })
70 })
71 })
72 .collect()
73}
74
75pub fn normalize(value: &str) -> String {
77 let mut normalized = String::with_capacity(value.len());
78 let mut separator = false;
79 for ch in value.chars() {
80 if matches!(ch, '-' | '_' | '.') {
81 if !separator {
82 normalized.push('-');
83 }
84 separator = true;
85 } else {
86 normalized.push(ch.to_ascii_lowercase());
87 separator = false;
88 }
89 }
90 normalized
91}
92
93pub(crate) fn specifiers(requirement: &Requirement) -> &[VersionSpecifier] {
94 match &requirement.version_or_url {
95 Some(VersionOrUrl::VersionSpecifier(specs)) => specs,
96 _ => &[],
97 }
98}
99
100pub fn parse_requirement(value: &str) -> Result<Requirement> {
103 let original_error = match value.parse::<Requirement>() {
104 Ok(requirement) => return Ok(requirement),
105 Err(error) => error,
106 };
107 static ARBITRARY: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"===\s*[^\s,;)]+").unwrap());
108 let (before, marker) = value
109 .split_once(';')
110 .map_or((value, ""), |(before, marker)| (before, marker));
111 let sanitized = ARBITRARY.replace_all(before, "===0");
112 if sanitized != before {
113 let sanitized = if value.contains(';') {
114 format!("{sanitized};{marker}")
115 } else {
116 sanitized.into_owned()
117 };
118 if let Ok(requirement) = sanitized.parse::<Requirement>()
119 && specifiers(&requirement)
120 .iter()
121 .any(|spec| *spec.operator() == Operator::ExactEqual)
122 {
123 return Ok(requirement);
124 }
125 }
126 Err(original_error.into())
127}
128
129pub fn baseline(requirement: &Requirement) -> Option<&VersionSpecifier> {
130 specifiers(requirement).iter().find(|spec| {
131 matches!(
132 spec.operator(),
133 Operator::GreaterThanEqual | Operator::Equal | Operator::TildeEqual
134 )
135 })
136}
137
138pub fn unsupported_reason(requirement: &Requirement) -> Option<&'static str> {
139 if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
140 return Some("URL dependency");
141 }
142 let specs = specifiers(requirement);
143 if specs.len() > 1 {
144 if specs
145 .iter()
146 .filter(|spec| *spec.operator() == Operator::GreaterThanEqual)
147 .count()
148 == 1
149 && specs.iter().all(|spec| {
150 matches!(
151 spec.operator(),
152 Operator::GreaterThanEqual
153 | Operator::LessThan
154 | Operator::LessThanEqual
155 | Operator::NotEqual
156 | Operator::NotEqualStar
157 )
158 })
159 {
160 return None;
161 }
162 return Some("unsupported composite version constraints");
163 }
164 if specs.first().is_some_and(|spec| {
165 !matches!(
166 spec.operator(),
167 Operator::Equal | Operator::GreaterThanEqual | Operator::TildeEqual
168 )
169 }) {
170 return Some("unsupported version constraint");
171 }
172 None
173}
174
175pub fn replacement(requirement: &Requirement, original: &str, latest: &Version) -> Option<String> {
177 let Some(spec) = baseline(requirement) else {
178 let (before, marker) = original
179 .split_once(';')
180 .map_or((original, None), |(before, marker)| (before, Some(marker)));
181 return Some(format!(
182 "{}>={latest}{}",
183 before.trim_end(),
184 marker.map_or_else(String::new, |marker| format!(" ;{marker}"))
185 ));
186 };
187 if latest <= spec.version() {
188 return None;
189 }
190 static FLOOR: LazyLock<Regex> =
191 LazyLock::new(|| Regex::new(r"(>=|==|~=)\s*([^\s,;)]+)").unwrap());
192 let before_marker = original.split(';').next().unwrap_or(original);
193 for captures in FLOOR.captures_iter(before_marker) {
194 let token = captures.get(2).unwrap();
195 if captures[1] == spec.operator().to_string()
196 && token.as_str().parse::<Version>().ok().as_ref() == Some(spec.version())
197 {
198 let mut result = original.to_owned();
199 result.replace_range(token.range(), &latest.to_string());
200 return Some(result);
201 }
202 }
203 None
204}
205
206pub fn version_change(update: &Update) -> &'static str {
208 let Ok(requirement) = update.dependency.requirement.parse::<Requirement>() else {
209 return "other";
210 };
211 let Some(spec) = baseline(&requirement) else {
212 return "unbounded";
213 };
214 let before = spec.version();
215 let after = &update.version;
216 if before.epoch() != after.epoch()
217 || [before, after].iter().any(|version| {
218 version.any_prerelease()
219 || version.is_post()
220 || version.is_local()
221 || version.release().len() > 3
222 })
223 {
224 return "other";
225 }
226 for (index, label) in ["major", "minor", "patch"].into_iter().enumerate() {
227 if before.release().get(index).copied().unwrap_or(0)
228 != after.release().get(index).copied().unwrap_or(0)
229 {
230 return label;
231 }
232 }
233 "other"
234}
235
236pub fn apply_update(document: &mut DocumentMut, update: &Update) -> Result<()> {
238 let mut item = document.as_item_mut();
239 for key in &update.dependency.section {
240 item = item.get_mut(key).with_context(|| {
241 format!(
242 "Missing dependency section: {}",
243 update.dependency.section.join(".")
244 )
245 })?;
246 }
247 let value = item
248 .as_array_mut()
249 .and_then(|array| array.get_mut(update.dependency.index))
250 .context("Missing dependency occurrence")?;
251 ensure!(
252 value.as_str() == Some(update.dependency.requirement.as_str()),
253 "Dependency occurrence changed before update"
254 );
255 let Value::String(string) = value else {
256 anyhow::bail!("Dependency occurrence is not a string");
257 };
258 let repr = string.display_repr();
259 let original = &update.dependency.requirement;
262 let prefix: usize = original
263 .chars()
264 .zip(update.replacement.chars())
265 .take_while(|(before, after)| before == after)
266 .map(|(character, _)| character.len_utf8())
267 .sum();
268 let suffix: usize = original[prefix..]
269 .chars()
270 .rev()
271 .zip(update.replacement[prefix..].chars().rev())
272 .take_while(|(before, after)| before == after)
273 .map(|(character, _)| character.len_utf8())
274 .sum();
275 let before = &original[prefix..original.len() - suffix];
276 let after = &update.replacement[prefix..update.replacement.len() - suffix];
277 for (start, _) in repr.match_indices(before) {
278 let mut candidate = repr.to_string();
279 candidate.replace_range(start..start + before.len(), after);
280 if let Ok(mut updated) = candidate.parse::<Value>()
281 && updated.as_str() == Some(update.replacement.as_str())
282 {
283 *updated.decor_mut() = value.decor().clone();
284 *value = updated;
285 return Ok(());
286 }
287 }
288 let delimiter = if repr.starts_with("\"\"\"") {
289 "\"\"\""
290 } else if repr.starts_with("'''") {
291 "'''"
292 } else if repr.starts_with('\'') {
293 "'"
294 } else {
295 "\""
296 };
297 let content = if delimiter.starts_with('"') {
298 let encoded = serde_json::to_string(&update.replacement)?;
299 encoded[1..encoded.len() - 1].to_owned()
300 } else {
301 update.replacement.clone()
302 };
303 let quoted = format!("{delimiter}{content}{delimiter}");
304 let mut updated = quoted
305 .parse::<Value>()
306 .ok()
307 .filter(|value| value.as_str() == Some(update.replacement.as_str()))
308 .unwrap_or_else(|| Value::from(update.replacement.clone()));
309 *updated.decor_mut() = value.decor().clone();
310 *value = updated;
311 Ok(())
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn requirement(value: &str) -> Requirement {
319 value.parse().unwrap()
320 }
321
322 fn version(value: &str) -> Version {
323 value.parse().unwrap()
324 }
325
326 fn update(original: &str, latest: &str) -> Update {
327 Update {
328 dependency: Dependency {
329 requirement: original.into(),
330 section: vec!["project".into(), "dependencies".into()],
331 index: 0,
332 },
333 replacement: replacement(&requirement(original), original, &version(latest)).unwrap(),
334 version: version(latest),
335 release_link: None,
336 project_link: None,
337 }
338 }
339
340 #[test]
341 fn reads_all_occurrences_without_include_tables() {
342 let document = r#"[project]
343dependencies = ["Demo>=1", 42, "bare"]
344[project.optional-dependencies]
345cli = ["Demo==1"]
346broken = "not an array"
347[dependency-groups]
348dev = [{include-group = "other"}, "Demo~=1.0"]
349"#
350 .parse::<DocumentMut>()
351 .unwrap();
352 let entries = dependencies(&document);
353 assert_eq!(entries.len(), 4);
354 assert_eq!(entries[1].index, 2);
355 assert_eq!(
356 entries[2].section,
357 ["project", "optional-dependencies", "cli"]
358 );
359 assert_eq!(entries[3].section, ["dependency-groups", "dev"]);
360 assert_eq!(entries[3].index, 1);
361 assert!(dependencies(&DocumentMut::new()).is_empty());
362 }
363
364 #[test]
365 fn canonicalizes_names() {
366 for name in ["demo-pkg", "Demo_Pkg", "DEMO.pkg", "demo.._-PKG"] {
367 assert_eq!(normalize(name), "demo-pkg");
368 }
369 }
370
371 #[test]
372 fn arbitrary_pins_are_valid_but_unsupported() {
373 for value in [
374 "demo===nightly",
375 "demo[foo]=== nightly; python_version > '3.10'",
376 ] {
377 let requirement = parse_requirement(value).unwrap();
378 assert_eq!(requirement.name.as_ref(), "demo");
379 assert_eq!(
380 unsupported_reason(&requirement),
381 Some("unsupported version constraint")
382 );
383 }
384 for value in [
385 "bad!===nightly",
386 "demo===nightly; invalid marker",
387 "demo>nightly",
388 ] {
389 assert!(parse_requirement(value).is_err(), "{value}");
390 }
391 }
392
393 #[test]
394 fn supported_constraints() {
395 for value in [
396 "demo",
397 "demo[extra]",
398 "demo==1",
399 "demo>=1",
400 "demo~=1.0",
401 "demo>=1,<2",
402 "demo>=1,<=2",
403 "demo>=1,<2,!=1.5",
404 "demo>=1,!=1.5.*",
405 "demo==1+internal",
406 ] {
407 assert_eq!(unsupported_reason(&requirement(value)), None, "{value}");
408 }
409 }
410
411 #[test]
412 fn unsupported_constraints() {
413 for value in ["demo==1.*", "demo!=1", "demo<2", "demo>1", "demo===1"] {
414 assert_eq!(
415 unsupported_reason(&requirement(value)),
416 Some("unsupported version constraint"),
417 "{value}"
418 );
419 }
420 for value in [
421 "demo>=1,>=1.2",
422 "demo==1,<2",
423 "demo~=1.0,<2",
424 "demo>1,<2",
425 "demo<2,!=1",
426 ] {
427 assert_eq!(
428 unsupported_reason(&requirement(value)),
429 Some("unsupported composite version constraints"),
430 "{value}"
431 );
432 }
433 assert_eq!(
434 unsupported_reason(&requirement("demo @ https://example.org/demo.whl")),
435 Some("URL dependency")
436 );
437 }
438
439 #[test]
440 fn replacement_preserves_constraints_and_spacing() {
441 for original in [
442 "Demo[extra] >= 1.0 , < 2 ; python_version >= '3.10'",
443 "Demo (>=1.0,<2,!=1.5)",
444 "Demo<2,>=1.0",
445 "Demo<=2, >=1.0",
446 "Demo==1.0",
447 "Demo~=1.0",
448 ] {
449 assert_eq!(
450 replacement(&requirement(original), original, &version("1.8")),
451 Some(original.replace("1.0", "1.8"))
452 );
453 }
454 let original = "demo[all]; python_version > '3.10'";
455 assert_eq!(
456 replacement(&requirement(original), original, &version("2")),
457 Some("demo[all]>=2 ; python_version > '3.10'".into())
458 );
459 assert_eq!(
460 replacement(&requirement("demo"), "demo", &version("2")),
461 Some("demo>=2".into())
462 );
463 assert_eq!(
464 replacement(&requirement("demo>=2"), "demo>=2", &version("2")),
465 None
466 );
467 assert_eq!(
468 replacement(&requirement("demo>=3"), "demo>=3", &version("2")),
469 None
470 );
471 assert_eq!(
472 replacement(
473 &requirement("demo==1.2.3+old"),
474 "demo==1.2.3+old",
475 &version("1.2.5+internal")
476 ),
477 Some("demo==1.2.5+internal".into())
478 );
479 assert_eq!(
480 replacement(&requirement("demo>=v1.0"), "demo>=v1.0", &version("2")),
481 Some("demo>=2".into())
482 );
483 }
484
485 #[test]
486 fn classifies_baseline_changes() {
487 for (original, latest, expected) in [
488 ("demo", "2", "unbounded"),
489 ("demo>=1", "2", "major"),
490 ("demo>=1.2.3", "1.3", "minor"),
491 ("demo>=1.2", "1.2.1", "patch"),
492 ("demo>=1.2", "1.2.0", "other"),
493 ("demo>=1", "1!2", "other"),
494 ("demo>=1", "2rc1", "other"),
495 ("demo>=1.dev1", "2", "other"),
496 ("demo>=1", "2.post1", "other"),
497 ("demo==1+old", "2", "other"),
498 ("demo>=1", "2.0.0.1", "other"),
499 ] {
500 let mut change = update(original, "9");
501 change.version = version(latest);
502 assert_eq!(version_change(&change), expected, "{original} -> {latest}");
503 }
504 }
505
506 #[test]
507 fn keeps_quote_styles_and_comments() {
508 for quote in ["\"", "'", "\"\"\"", "'''"] {
509 let original = format!(
510 "[project]\ndependencies = [\n {quote}demo>=1.0{quote}, # Keep comment.\n]\n"
511 );
512 let mut document = original.parse::<DocumentMut>().unwrap();
513 apply_update(&mut document, &update("demo>=1.0", "2.0")).unwrap();
514 assert_eq!(document.to_string(), original.replace("1.0", "2.0"));
515 }
516 }
517
518 #[test]
519 fn updates_only_selected_occurrence() {
520 let original = "[project]\ndependencies = [\"demo>=1.0\", 'demo[all]>=1.0; python_version < \"3.14\"'] # Keep\n[project.optional-dependencies]\ncli = [\"demo>=1.0\"]\n[dependency-groups]\ndev = [{include-group = \"other\"}, \"demo>=1.0\"]\n";
521 let mut document = original.parse::<DocumentMut>().unwrap();
522 let selected = dependencies(&document)[1].clone();
523 let mut change = update(&selected.requirement, "2.0");
524 change.dependency = selected;
525 apply_update(&mut document, &change).unwrap();
526 assert_eq!(
527 document.to_string(),
528 original.replace("demo[all]>=1.0", "demo[all]>=2.0")
529 );
530 assert!(apply_update(&mut document, &change).is_err());
531 }
532
533 #[test]
534 fn keeps_multiline_whitespace_and_unrelated_escape_sequences() {
535 for original in [
536 "[project]\ndependencies = [\"\"\"demo>=1.0;\\n python_version < \\\"3.14\\\"\"\"\"]\n",
537 "[project]\ndependencies = [\"\"\"demo>=1.0;\n python_version < '3.14'\"\"\"]\n",
538 "[project]\ndependencies = [\"demo>=1.0; python_version < '\\u0033.14'\"]\n",
539 ] {
540 let mut document = original.parse::<DocumentMut>().unwrap();
541 let entry = dependencies(&document).remove(0);
542 let mut change = update(&entry.requirement, "2.0");
543 change.dependency = entry;
544 apply_update(&mut document, &change).unwrap();
545 assert_eq!(
546 document.to_string(),
547 original.replace("demo>=1.0", "demo>=2.0")
548 );
549 }
550 }
551
552 #[test]
553 fn keeps_basic_quotes_with_escaped_markers() {
554 let original = r#"[project]
555dependencies = ["demo>=1; python_version < \"3.14\""]
556"#;
557 let mut document = original.parse::<DocumentMut>().unwrap();
558 let entry = dependencies(&document).remove(0);
559 let mut change = update(&entry.requirement, "2");
560 change.dependency = entry;
561 apply_update(&mut document, &change).unwrap();
562 assert_eq!(document.to_string(), original.replace("demo>=1", "demo>=2"));
563 }
564}