Skip to main content

check_updates/
dependencies.rs

1//! Read dependency occurrences and update their declared version floors.
2
3use 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
26/// Visit runtime requirements, extras, and groups, but not group include tables.
27pub 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
75/// Apply PEP 503 name normalization, also used for group and extra selectors.
76pub 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
100/// Accept arbitrary equality pins as unsupported, even when their token is not
101/// a PEP 440 version. Strictly validate the name, extras, and markers as usual.
102pub 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
175/// Change only the floor token. Keep extras, bounds, markers, and spacing.
176pub 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
206/// Describe the declared baseline, not an installed version or compatibility.
207pub 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
236/// Replace one occurrence and keep its string delimiters and TOML decoration.
237pub 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    // Try a source-level token edit first. This keeps multiline whitespace and
260    // escape sequences outside the changed version exactly as written.
261    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}