upd-cli 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {upd_cli-0.1.2 → upd_cli-0.1.3}/CHANGELOG.md +16 -0
  2. {upd_cli-0.1.2 → upd_cli-0.1.3}/Cargo.lock +1 -1
  3. {upd_cli-0.1.2 → upd_cli-0.1.3}/Cargo.toml +1 -1
  4. {upd_cli-0.1.2 → upd_cli-0.1.3}/PKG-INFO +32 -2
  5. {upd_cli-0.1.2 → upd_cli-0.1.3}/README.md +31 -1
  6. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/audit/mod.rs +38 -39
  7. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/cli.rs +3 -2
  8. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/cooldown.rs +1 -40
  9. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/lockfile.rs +174 -71
  10. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/main.rs +48 -5
  11. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/mod.rs +2 -0
  12. upd_cli-0.1.3/src/updater/npm_range.rs +257 -0
  13. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/package_json.rs +432 -17
  14. upd_cli-0.1.3/src/version/compare.rs +79 -0
  15. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/version/mod.rs +1 -0
  16. {upd_cli-0.1.2 → upd_cli-0.1.3}/.mise.toml +0 -0
  17. {upd_cli-0.1.2 → upd_cli-0.1.3}/.pre-commit-config.yaml +0 -0
  18. {upd_cli-0.1.2 → upd_cli-0.1.3}/.pre-commit-hooks.yaml +0 -0
  19. {upd_cli-0.1.2 → upd_cli-0.1.3}/.rumdl.toml +0 -0
  20. {upd_cli-0.1.2 → upd_cli-0.1.3}/LICENSE +0 -0
  21. {upd_cli-0.1.2 → upd_cli-0.1.3}/Makefile +0 -0
  22. {upd_cli-0.1.2 → upd_cli-0.1.3}/assets/logo-wide.svg +0 -0
  23. {upd_cli-0.1.2 → upd_cli-0.1.3}/assets/logo.svg +0 -0
  24. {upd_cli-0.1.2 → upd_cli-0.1.3}/pyproject.toml +0 -0
  25. {upd_cli-0.1.2 → upd_cli-0.1.3}/python/upd_cli/__init__.py +0 -0
  26. {upd_cli-0.1.2 → upd_cli-0.1.3}/python/upd_cli/__main__.py +0 -0
  27. {upd_cli-0.1.2 → upd_cli-0.1.3}/python/upd_cli/py.typed +0 -0
  28. {upd_cli-0.1.2 → upd_cli-0.1.3}/rust-toolchain.toml +0 -0
  29. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/align.rs +0 -0
  30. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/audit/cache.rs +0 -0
  31. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/audit/cvss.rs +0 -0
  32. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/cache.rs +0 -0
  33. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/config.rs +0 -0
  34. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/interactive.rs +0 -0
  35. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/lib.rs +0 -0
  36. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/output.rs +0 -0
  37. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/crates_io.rs +0 -0
  38. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/github_releases.rs +0 -0
  39. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/go_proxy.rs +0 -0
  40. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/mock.rs +0 -0
  41. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/mod.rs +0 -0
  42. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/npm.rs +0 -0
  43. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/nuget.rs +0 -0
  44. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/pypi.rs +0 -0
  45. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/rubygems.rs +0 -0
  46. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/terraform.rs +0 -0
  47. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/registry/utils.rs +0 -0
  48. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/cargo_toml.rs +0 -0
  49. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/csproj.rs +0 -0
  50. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/gemfile.rs +0 -0
  51. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/github_actions.rs +0 -0
  52. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/go_mod.rs +0 -0
  53. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/mise.rs +0 -0
  54. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/pre_commit.rs +0 -0
  55. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/pyproject.rs +0 -0
  56. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/requirements.rs +0 -0
  57. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/updater/terraform.rs +0 -0
  58. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/version/pep440.rs +0 -0
  59. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/version/semver_util.rs +0 -0
  60. {upd_cli-0.1.2 → upd_cli-0.1.3}/src/version/tag.rs +0 -0
  61. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/audit_offline.rs +0 -0
  62. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/audit_sarif.rs +0 -0
  63. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/audit_severity.rs +0 -0
  64. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/bump_filter.rs +0 -0
  65. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/cooldown_e2e.rs +0 -0
  66. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/exit_codes.rs +0 -0
  67. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/fix_audit.rs +0 -0
  68. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/format_json.rs +0 -0
  69. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/help_text.rs +0 -0
  70. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/interactive_tty.rs +0 -0
  71. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/invalid_positional.rs +0 -0
  72. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/no_args_scope.rs +0 -0
  73. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/output_streams.rs +0 -0
  74. {upd_cli-0.1.2 → upd_cli-0.1.3}/tests/package_filter.rs +0 -0
  75. {upd_cli-0.1.2 → upd_cli-0.1.3}/vership.toml +0 -0
@@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
12
 
13
13
 
14
14
 
15
+
16
+ ## [0.1.3](https://github.com/rvben/upd/compare/v0.1.2...v0.1.3) - 2026-04-25
17
+
18
+ ### Added
19
+
20
+ - **lock**: scope lockfile regeneration to the packages upd actually changed ([6b6cfa6](https://github.com/rvben/upd/commit/6b6cfa6fe3e8e8d787e78c7afea24883ebe67833))
21
+ - **npm**: classify and rewrite comparator-range specs ([a60979a](https://github.com/rvben/upd/commit/a60979acd7edb7eb6adfac554a590412a6cf8271))
22
+
23
+ ### Fixed
24
+
25
+ - **lock**: include config pins in targeted regenerate and update CLI help ([207fdf9](https://github.com/rvben/upd/commit/207fdf94af95c74865f828f258ec9cfa61d22085))
26
+ - **npm**: preserve upper bound when pinning comparator-range specs ([fb7c863](https://github.com/rvben/upd/commit/fb7c863de9f07201af310ab13a59696d17fcb766))
27
+ - **npm**: apply config policy and cooldown to comparator-range updates ([be095dd](https://github.com/rvben/upd/commit/be095dd30443dd547fb6913dae551cbd530cb019))
28
+ - **npm**: update comparator-range specs via constraint-aware resolution ([4481b8b](https://github.com/rvben/upd/commit/4481b8bad6a02c6f761cf489a547b5546099e871))
29
+ - **audit**: order fix versions numerically, not lexicographically ([069eb1d](https://github.com/rvben/upd/commit/069eb1dc8771640376957339e40ada7b60a82b0a))
30
+
15
31
  ## [0.1.2](https://github.com/rvben/upd/compare/v0.1.1...v0.1.2) - 2026-04-24
16
32
 
17
33
  ### Added
@@ -2024,7 +2024,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2024
2024
 
2025
2025
  [[package]]
2026
2026
  name = "upd"
2027
- version = "0.1.2"
2027
+ version = "0.1.3"
2028
2028
  dependencies = [
2029
2029
  "anyhow",
2030
2030
  "async-trait",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "upd"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.95.0"
6
6
  description = "A fast dependency updater for Python, Node.js, Rust, Go, Ruby, Terraform, GitHub Actions, pre-commit, and Mise projects"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upd-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -46,7 +46,10 @@ pipx run --spec upd-cli upd --apply
46
46
 
47
47
  - **Multi-ecosystem**: Python, Node.js, Rust, Go, Ruby, .NET, Terraform, GitHub Actions, pre-commit, Mise/asdf
48
48
  - **Fast**: Parallel registry requests for all dependencies
49
- - **Constraint-aware**: Respects version constraints like `>=2.0,<3` and `~> 7.1`
49
+ - **Constraint-aware**: Respects `>=2.0,<3` (Python), `~> 7.1` (Ruby), and `^2.0.0` / `~2.0.0` (npm, Cargo).
50
+ For npm, comparator ranges such as `">=1.0.0 <2.0.0"` are rewritten with a **bump strategy**: the lower
51
+ bound moves to the highest version satisfying the constraint, preserving the upper bound. Hyphen
52
+ (`"1 - 2"`) and OR (`"^1 || ^2"`) ranges are reported as warnings and left untouched.
50
53
  - **Smart caching**: 24-hour version cache for faster subsequent runs
51
54
  - **Update filters**: Filter by bump level with `--only-bump <major|minor|patch>` (repeatable) or cap with `--max-bump`
52
55
  - **Interactive mode**: Approve updates individually with `-i`
@@ -767,6 +770,33 @@ Global flags (accepted on every subcommand):
767
770
 
768
771
  Subcommands: `update` (default), `align`, `audit`, `clean-cache`, `self-update`.
769
772
 
773
+ #### Commands run by `--lock`
774
+
775
+ `upd --lock` runs the narrowest per-ecosystem refresh command that
776
+ updates only the packages `upd` just rewrote. Targeted forms are used
777
+ wherever the package manager supports them; targeting falls back to
778
+ `--lockfile-only` flags where no per-package form exists; otherwise
779
+ the manifest-wide refresh command is used.
780
+
781
+ | Ecosystem | Lockfile | Command |
782
+ |-----------|--------------------------|------------------------------------------------|
783
+ | Python | `poetry.lock` | `poetry lock --no-update` |
784
+ | Python | `uv.lock` | `uv lock` |
785
+ | Node | `package-lock.json` | `npm install --package-lock-only` |
786
+ | Node | `yarn.lock` | `yarn install --mode update-lockfile` (Yarn 2+)|
787
+ | Node | `pnpm-lock.yaml` | `pnpm install --lockfile-only` |
788
+ | Node | `bun.lockb` | `bun install` |
789
+ | Rust | `Cargo.lock` | `cargo update -p <changed> -p <changed> …` |
790
+ | Go | `go.sum` | `go mod tidy` (no targeted form) |
791
+ | Ruby | `Gemfile.lock` | `bundle lock --update <changed> …` |
792
+ | .NET | `packages.lock.json` | `dotnet restore` (no targeted form) |
793
+ | Terraform | `.terraform.lock.hcl` | `terraform providers lock` (no targeted form) |
794
+
795
+ Manifests whose `upd` pass produced zero changes have their lockfile
796
+ refresh skipped entirely. A directory where only config pins were
797
+ applied is still refreshed, and the changed-package list includes
798
+ those pinned packages so `cargo update -p <pkg>` / `bundle lock --update <pkg>` stay scoped.
799
+
770
800
  Stable `audit`-specific flags:
771
801
 
772
802
  | Flag | Purpose |
@@ -23,7 +23,10 @@ pipx run --spec upd-cli upd --apply
23
23
 
24
24
  - **Multi-ecosystem**: Python, Node.js, Rust, Go, Ruby, .NET, Terraform, GitHub Actions, pre-commit, Mise/asdf
25
25
  - **Fast**: Parallel registry requests for all dependencies
26
- - **Constraint-aware**: Respects version constraints like `>=2.0,<3` and `~> 7.1`
26
+ - **Constraint-aware**: Respects `>=2.0,<3` (Python), `~> 7.1` (Ruby), and `^2.0.0` / `~2.0.0` (npm, Cargo).
27
+ For npm, comparator ranges such as `">=1.0.0 <2.0.0"` are rewritten with a **bump strategy**: the lower
28
+ bound moves to the highest version satisfying the constraint, preserving the upper bound. Hyphen
29
+ (`"1 - 2"`) and OR (`"^1 || ^2"`) ranges are reported as warnings and left untouched.
27
30
  - **Smart caching**: 24-hour version cache for faster subsequent runs
28
31
  - **Update filters**: Filter by bump level with `--only-bump <major|minor|patch>` (repeatable) or cap with `--max-bump`
29
32
  - **Interactive mode**: Approve updates individually with `-i`
@@ -744,6 +747,33 @@ Global flags (accepted on every subcommand):
744
747
 
745
748
  Subcommands: `update` (default), `align`, `audit`, `clean-cache`, `self-update`.
746
749
 
750
+ #### Commands run by `--lock`
751
+
752
+ `upd --lock` runs the narrowest per-ecosystem refresh command that
753
+ updates only the packages `upd` just rewrote. Targeted forms are used
754
+ wherever the package manager supports them; targeting falls back to
755
+ `--lockfile-only` flags where no per-package form exists; otherwise
756
+ the manifest-wide refresh command is used.
757
+
758
+ | Ecosystem | Lockfile | Command |
759
+ |-----------|--------------------------|------------------------------------------------|
760
+ | Python | `poetry.lock` | `poetry lock --no-update` |
761
+ | Python | `uv.lock` | `uv lock` |
762
+ | Node | `package-lock.json` | `npm install --package-lock-only` |
763
+ | Node | `yarn.lock` | `yarn install --mode update-lockfile` (Yarn 2+)|
764
+ | Node | `pnpm-lock.yaml` | `pnpm install --lockfile-only` |
765
+ | Node | `bun.lockb` | `bun install` |
766
+ | Rust | `Cargo.lock` | `cargo update -p <changed> -p <changed> …` |
767
+ | Go | `go.sum` | `go mod tidy` (no targeted form) |
768
+ | Ruby | `Gemfile.lock` | `bundle lock --update <changed> …` |
769
+ | .NET | `packages.lock.json` | `dotnet restore` (no targeted form) |
770
+ | Terraform | `.terraform.lock.hcl` | `terraform providers lock` (no targeted form) |
771
+
772
+ Manifests whose `upd` pass produced zero changes have their lockfile
773
+ refresh skipped entirely. A directory where only config pins were
774
+ applied is still refreshed, and the changed-package list includes
775
+ those pinned packages so `cargo update -p <pkg>` / `bundle lock --update <pkg>` stay scoped.
776
+
747
777
  Stable `audit`-specific flags:
748
778
 
749
779
  | Flag | Purpose |
@@ -134,7 +134,7 @@ pub fn compute_fix_plan(audit: &AuditResult) -> (HashMap<String, String>, Vec<(S
134
134
  .vulnerabilities
135
135
  .iter()
136
136
  .filter_map(|v| v.fixed_version.as_deref())
137
- .max_by(|a, b| compare_fix_versions(a, b));
137
+ .max_by(|a, b| crate::version::compare::compare_versions(a, b));
138
138
 
139
139
  if let Some(version) = max_fixed {
140
140
  fixable.insert(name.clone(), version.to_string());
@@ -144,44 +144,6 @@ pub fn compute_fix_plan(audit: &AuditResult) -> (HashMap<String, String>, Vec<(S
144
144
  (fixable, unfixable)
145
145
  }
146
146
 
147
- /// Compare two version strings for ordering, preferring semver but falling back
148
- /// to lexicographic comparison for non-semver ecosystems.
149
- fn compare_fix_versions(a: &str, b: &str) -> std::cmp::Ordering {
150
- match (semver_parse(a), semver_parse(b)) {
151
- (Some(va), Some(vb)) => va.cmp(&vb),
152
- _ => a.cmp(b),
153
- }
154
- }
155
-
156
- /// Parse a version string as semver, accepting an optional leading `v`.
157
- ///
158
- /// Returns `(major, minor, patch, is_stable)` where `is_stable` is 1 for stable
159
- /// releases and 0 for pre-releases (e.g. `2.0.0-rc1`). This ensures stable
160
- /// versions beat pre-releases when all numeric components are equal.
161
- fn semver_parse(v: &str) -> Option<(u64, u64, u64, u8)> {
162
- let v = v.trim_start_matches('v');
163
- let parts: Vec<&str> = v.split('.').collect();
164
- if parts.len() < 2 {
165
- return None;
166
- }
167
- let major: u64 = parts[0].parse().ok()?;
168
- let minor: u64 = parts[1].parse().ok()?;
169
- // Patch may carry a pre-release or build suffix (e.g. "0-rc1", "3+build1").
170
- // Take only the leading digit run so "0-rc1" → patch=0, rest="-rc1".
171
- let patch_part = parts.get(2).copied().unwrap_or("0");
172
- let (patch_digits, rest) = patch_part
173
- .split_once(|c: char| !c.is_ascii_digit())
174
- .unwrap_or((patch_part, ""));
175
- let patch: u64 = if patch_digits.is_empty() {
176
- 0
177
- } else {
178
- patch_digits.parse().ok()?
179
- };
180
- // Stability flag: 1 = stable, 0 = pre-release. Stable wins ties.
181
- let is_stable: u8 = if rest.is_empty() { 1 } else { 0 };
182
- Some((major, minor, patch, is_stable))
183
- }
184
-
185
147
  /// Return a sort key for a severity string such that Critical sorts first.
186
148
  ///
187
149
  /// Lower numeric values sort earlier, so Critical = 0, Unknown = 5.
@@ -858,6 +820,43 @@ mod tests {
858
820
  assert_eq!(fixable.get("pkg").map(|s| s.as_str()), Some("2.10.0"));
859
821
  }
860
822
 
823
+ #[test]
824
+ fn test_compute_fix_plan_picks_numerically_highest_non_semver_fix() {
825
+ // Regression test for 4-segment versions (e.g. Ruby or multi-segment tags)
826
+ // where the legacy `semver_parse` dropped the 4th segment, making
827
+ // `1.0.0.10` and `1.0.0.9` compare equal. `max_by` then returned the
828
+ // *last* element, so the answer depended on fix_version ordering.
829
+ //
830
+ // The correct "minimum safe fix" must order by full numeric value.
831
+ let mut audit = AuditResult::default();
832
+ audit.vulnerable.push(PackageAuditResult {
833
+ package: Package {
834
+ name: "pkg".to_string(),
835
+ version: "1.0.0.1".to_string(),
836
+ ecosystem: Ecosystem::RubyGems,
837
+ },
838
+ vulnerabilities: vec![
839
+ Vulnerability {
840
+ id: "CVE-A".to_string(),
841
+ summary: None,
842
+ severity: None,
843
+ url: None,
844
+ fixed_version: Some("1.0.0.10".to_string()),
845
+ },
846
+ Vulnerability {
847
+ id: "CVE-B".to_string(),
848
+ summary: None,
849
+ severity: None,
850
+ url: None,
851
+ fixed_version: Some("1.0.0.9".to_string()),
852
+ },
853
+ ],
854
+ });
855
+
856
+ let (fixable, _) = compute_fix_plan(&audit);
857
+ assert_eq!(fixable.get("pkg").map(String::as_str), Some("1.0.0.10"));
858
+ }
859
+
861
860
  // ─── check_packages_cached unit tests ────────────────────────────────────
862
861
 
863
862
  fn sample_package(name: &str) -> Package {
@@ -130,8 +130,9 @@ pub struct Cli {
130
130
 
131
131
  /// Regenerate lockfiles after updating.
132
132
  ///
133
- /// Runs the appropriate lock command for each ecosystem (e.g. `poetry lock`,
134
- /// `npm install`, `cargo update`).
133
+ /// Runs the narrowest per-ecosystem refresh command that updates only the
134
+ /// packages `upd` just rewrote (e.g. `cargo update -p <pkg>`,
135
+ /// `bundle lock --update <pkg>`, `npm install --package-lock-only`).
135
136
  #[arg(long, global = true)]
136
137
  pub lock: bool,
137
138
 
@@ -6,6 +6,7 @@ use anyhow::{Result, anyhow};
6
6
  use chrono::{DateTime, Duration, Utc};
7
7
 
8
8
  use crate::registry::VersionMeta;
9
+ use crate::version::compare::compare_versions;
9
10
 
10
11
  /// Parse a cooldown duration string.
11
12
  ///
@@ -238,46 +239,6 @@ pub fn select(
238
239
  }
239
240
  }
240
241
 
241
- /// Version comparison: try semver first, fall back to numeric-aware segment
242
- /// comparison.
243
- ///
244
- /// The fallback splits on `.` and `-` and compares integer segments as numbers
245
- /// (so `1.10 > 1.9` and `v0.10.0.0 > v0.9.0.0`). Lexicographic string compare
246
- /// would get those wrong, which breaks selection across non-strict-semver
247
- /// ecosystems like PyPI and multi-segment GitHub tags.
248
- fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
249
- let stripped_a = a.strip_prefix('v').unwrap_or(a);
250
- let stripped_b = b.strip_prefix('v').unwrap_or(b);
251
- if let (Ok(va), Ok(vb)) = (
252
- semver::Version::parse(stripped_a),
253
- semver::Version::parse(stripped_b),
254
- ) {
255
- return va.cmp(&vb);
256
- }
257
- compare_loose(stripped_a, stripped_b)
258
- }
259
-
260
- fn compare_loose(a: &str, b: &str) -> std::cmp::Ordering {
261
- let mut a_parts = a.split(['.', '-']);
262
- let mut b_parts = b.split(['.', '-']);
263
- loop {
264
- match (a_parts.next(), b_parts.next()) {
265
- (None, None) => return std::cmp::Ordering::Equal,
266
- (None, Some(_)) => return std::cmp::Ordering::Less,
267
- (Some(_), None) => return std::cmp::Ordering::Greater,
268
- (Some(x), Some(y)) => {
269
- let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
270
- (Ok(nx), Ok(ny)) => nx.cmp(&ny),
271
- _ => x.cmp(y),
272
- };
273
- if ord != std::cmp::Ordering::Equal {
274
- return ord;
275
- }
276
- }
277
- }
278
- }
279
- }
280
-
281
242
  fn is_newer(candidate: &str, current: &str) -> bool {
282
243
  compare_versions(candidate, current) == std::cmp::Ordering::Greater
283
244
  }
@@ -50,30 +50,20 @@ impl RegenOutcome {
50
50
  }
51
51
  }
52
52
 
53
- /// Lockfile types and their associated commands
53
+ /// Lockfile variants supported by `upd --lock`. See `command()` for the
54
+ /// concrete invocation used per variant.
54
55
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
55
56
  pub enum LockfileType {
56
- /// poetry.lock - regenerated with `poetry lock --no-update`
57
57
  PoetryLock,
58
- /// uv.lock - regenerated with `uv lock`
59
58
  UvLock,
60
- /// package-lock.json - regenerated with `npm install`
61
59
  PackageLockJson,
62
- /// yarn.lock - regenerated with `yarn install`
63
60
  YarnLock,
64
- /// pnpm-lock.yaml - regenerated with `pnpm install`
65
61
  PnpmLock,
66
- /// bun.lockb - regenerated with `bun install`
67
62
  BunLock,
68
- /// Cargo.lock - regenerated with `cargo update`
69
63
  CargoLock,
70
- /// go.sum - regenerated with `go mod tidy`
71
64
  GoSum,
72
- /// Gemfile.lock - regenerated with `bundle install`
73
65
  GemfileLock,
74
- /// packages.lock.json - regenerated with `dotnet restore`
75
66
  PackagesLockJson,
76
- /// .terraform.lock.hcl - regenerated with `terraform providers lock`
77
67
  TerraformLock,
78
68
  }
79
69
 
@@ -95,20 +85,71 @@ impl LockfileType {
95
85
  }
96
86
  }
97
87
 
98
- /// Get the command to regenerate this lockfile
99
- pub fn command(&self) -> (&'static str, &'static [&'static str]) {
88
+ /// Returns the command + args to regenerate this lockfile.
89
+ ///
90
+ /// `changed` is the list of package names that `upd` just rewrote in the
91
+ /// corresponding manifest. Ecosystems whose CLI supports a targeted form use
92
+ /// it (`cargo update -p …`, `bundle lock --update …`). Ecosystems whose CLI
93
+ /// supports a lockfile-only flag prefer that over a full install. Everything
94
+ /// else falls back to the manifest-wide refresh command.
95
+ pub fn command(&self, changed: &[String]) -> (&'static str, Vec<String>) {
100
96
  match self {
101
- LockfileType::PoetryLock => ("poetry", &["lock", "--no-update"]),
102
- LockfileType::UvLock => ("uv", &["lock"]),
103
- LockfileType::PackageLockJson => ("npm", &["install"]),
104
- LockfileType::YarnLock => ("yarn", &["install"]),
105
- LockfileType::PnpmLock => ("pnpm", &["install"]),
106
- LockfileType::BunLock => ("bun", &["install"]),
107
- LockfileType::CargoLock => ("cargo", &["update"]),
108
- LockfileType::GoSum => ("go", &["mod", "tidy"]),
109
- LockfileType::GemfileLock => ("bundle", &["install"]),
110
- LockfileType::PackagesLockJson => ("dotnet", &["restore"]),
111
- LockfileType::TerraformLock => ("terraform", &["providers", "lock"]),
97
+ LockfileType::PoetryLock => (
98
+ "poetry",
99
+ vec!["lock".to_string(), "--no-update".to_string()],
100
+ ),
101
+ LockfileType::UvLock => ("uv", vec!["lock".to_string()]),
102
+ LockfileType::PackageLockJson => (
103
+ "npm",
104
+ vec!["install".to_string(), "--package-lock-only".to_string()],
105
+ ),
106
+ // Yarn Berry (v2+) only: `--mode update-lockfile` is the only
107
+ // documented form that refreshes `yarn.lock` without running
108
+ // install scripts. Yarn 1 (Classic) does not accept `--mode` and
109
+ // will error; Yarn 1 reached EOL in 2023 and is unsupported here.
110
+ LockfileType::YarnLock => (
111
+ "yarn",
112
+ vec![
113
+ "install".to_string(),
114
+ "--mode".to_string(),
115
+ "update-lockfile".to_string(),
116
+ ],
117
+ ),
118
+ LockfileType::PnpmLock => (
119
+ "pnpm",
120
+ vec!["install".to_string(), "--lockfile-only".to_string()],
121
+ ),
122
+ LockfileType::BunLock => ("bun", vec!["install".to_string()]),
123
+ LockfileType::CargoLock => {
124
+ if changed.is_empty() {
125
+ (
126
+ "cargo",
127
+ vec!["update".to_string(), "--workspace".to_string()],
128
+ )
129
+ } else {
130
+ let mut args = vec!["update".to_string()];
131
+ for pkg in changed {
132
+ args.push("-p".to_string());
133
+ args.push(pkg.clone());
134
+ }
135
+ ("cargo", args)
136
+ }
137
+ }
138
+ LockfileType::GoSum => ("go", vec!["mod".to_string(), "tidy".to_string()]),
139
+ LockfileType::GemfileLock => {
140
+ if changed.is_empty() {
141
+ ("bundle", vec!["lock".to_string()])
142
+ } else {
143
+ let mut args = vec!["lock".to_string(), "--update".to_string()];
144
+ args.extend(changed.iter().cloned());
145
+ ("bundle", args)
146
+ }
147
+ }
148
+ LockfileType::PackagesLockJson => ("dotnet", vec!["restore".to_string()]),
149
+ LockfileType::TerraformLock => (
150
+ "terraform",
151
+ vec!["providers".to_string(), "lock".to_string()],
152
+ ),
112
153
  }
113
154
  }
114
155
 
@@ -249,15 +290,21 @@ pub fn tool_available(tool: &str) -> bool {
249
290
 
250
291
  /// Regenerate a single lockfile by running the appropriate package manager.
251
292
  ///
293
+ /// `changed` is the list of package names that `upd` just rewrote in the
294
+ /// corresponding manifest. This is forwarded to [`LockfileType::command`] so
295
+ /// ecosystems that support targeted commands (e.g. `cargo update -p …`) only
296
+ /// touch the packages that actually changed.
297
+ ///
252
298
  /// Returns a [`RegenOutcome`] distinguishing success, missing tool, and
253
299
  /// command failure.
254
- pub fn regenerate_lockfile(
300
+ pub(crate) fn regenerate_lockfile(
255
301
  manifest_path: &Path,
256
302
  lockfile_type: LockfileType,
303
+ changed: &[String],
257
304
  verbose: bool,
258
305
  ) -> RegenOutcome {
259
306
  let dir = manifest_path.parent().unwrap_or(Path::new("."));
260
- let (cmd, args) = lockfile_type.command();
307
+ let (cmd, args) = lockfile_type.command(changed);
261
308
 
262
309
  if !tool_available(cmd) {
263
310
  return RegenOutcome::ToolMissing {
@@ -279,7 +326,7 @@ pub fn regenerate_lockfile(
279
326
  );
280
327
  }
281
328
 
282
- let output = match Command::new(cmd).args(args).current_dir(dir).output() {
329
+ let output = match Command::new(cmd).args(&args).current_dir(dir).output() {
283
330
  Ok(o) => o,
284
331
  Err(e) => {
285
332
  return RegenOutcome::Failed {
@@ -330,10 +377,19 @@ impl LockfileRegenResult {
330
377
 
331
378
  /// Regenerate all lockfiles for a manifest, returning a structured result.
332
379
  ///
380
+ /// `changed` is the list of package names that `upd` just rewrote in the
381
+ /// corresponding manifest. It is forwarded to each [`regenerate_lockfile`]
382
+ /// call so ecosystems that support targeted commands only touch the packages
383
+ /// that actually changed.
384
+ ///
333
385
  /// If no lockfiles are detected the caller is responsible for emitting the
334
386
  /// `note:` skip message; this function sets `no_lockfiles = true` to signal
335
387
  /// that.
336
- pub fn regenerate_lockfiles(manifest_path: &Path, verbose: bool) -> LockfileRegenResult {
388
+ pub fn regenerate_lockfiles(
389
+ manifest_path: &Path,
390
+ changed: &[String],
391
+ verbose: bool,
392
+ ) -> LockfileRegenResult {
337
393
  let lockfiles = detect_lockfiles(manifest_path);
338
394
 
339
395
  if lockfiles.is_empty() {
@@ -345,7 +401,7 @@ pub fn regenerate_lockfiles(manifest_path: &Path, verbose: bool) -> LockfileRege
345
401
 
346
402
  let outcomes = lockfiles
347
403
  .into_iter()
348
- .map(|lf| regenerate_lockfile(manifest_path, lf, verbose))
404
+ .map(|lf| regenerate_lockfile(manifest_path, lf, changed, verbose))
349
405
  .collect();
350
406
 
351
407
  LockfileRegenResult {
@@ -377,29 +433,104 @@ mod tests {
377
433
 
378
434
  #[test]
379
435
  fn test_lockfile_type_command() {
380
- let (cmd, args) = LockfileType::PoetryLock.command();
436
+ let (cmd, args) = LockfileType::PoetryLock.command(&[]);
381
437
  assert_eq!(cmd, "poetry");
382
438
  assert_eq!(args, &["lock", "--no-update"]);
383
439
 
384
- let (cmd, args) = LockfileType::UvLock.command();
440
+ let (cmd, args) = LockfileType::UvLock.command(&[]);
385
441
  assert_eq!(cmd, "uv");
386
442
  assert_eq!(args, &["lock"]);
443
+ }
387
444
 
388
- let (cmd, args) = LockfileType::PackageLockJson.command();
445
+ #[test]
446
+ fn test_package_lock_json_uses_package_lock_only_flag() {
447
+ let (cmd, args) = LockfileType::PackageLockJson.command(&["react".to_string()]);
389
448
  assert_eq!(cmd, "npm");
390
- assert_eq!(args, &["install"]);
449
+ assert_eq!(args, vec!["install", "--package-lock-only"]);
450
+ }
391
451
 
392
- let (cmd, args) = LockfileType::BunLock.command();
393
- assert_eq!(cmd, "bun");
394
- assert_eq!(args, &["install"]);
452
+ #[test]
453
+ fn test_pnpm_lock_uses_lockfile_only_flag() {
454
+ let (cmd, args) = LockfileType::PnpmLock.command(&["react".to_string()]);
455
+ assert_eq!(cmd, "pnpm");
456
+ assert_eq!(args, vec!["install", "--lockfile-only"]);
457
+ }
395
458
 
396
- let (cmd, args) = LockfileType::CargoLock.command();
459
+ #[test]
460
+ fn test_yarn_lock_uses_mode_update_lockfile_flag() {
461
+ // Yarn Berry (2+) supports --mode update-lockfile; it is the only
462
+ // documented flag that refreshes the lockfile without running install
463
+ // scripts.
464
+ let (cmd, args) = LockfileType::YarnLock.command(&["react".to_string()]);
465
+ assert_eq!(cmd, "yarn");
466
+ assert_eq!(args, vec!["install", "--mode", "update-lockfile"]);
467
+ }
468
+
469
+ #[test]
470
+ fn test_cargo_lock_passes_each_changed_package_to_update_p() {
471
+ let changed = vec!["serde".to_string(), "tokio".to_string()];
472
+ let (cmd, args) = LockfileType::CargoLock.command(&changed);
473
+ assert_eq!(cmd, "cargo");
474
+ assert_eq!(args, vec!["update", "-p", "serde", "-p", "tokio"]);
475
+ }
476
+
477
+ #[test]
478
+ fn test_cargo_lock_with_empty_changed_list_stays_workspace_broad() {
479
+ // Defensive: an empty changed list should never reach command() from the
480
+ // update path, but if it does (e.g. the `upd lock` subcommand) we emit the
481
+ // broad workspace update so nothing silently regresses.
482
+ let (cmd, args) = LockfileType::CargoLock.command(&[]);
397
483
  assert_eq!(cmd, "cargo");
398
- assert_eq!(args, &["update"]);
484
+ assert_eq!(args, vec!["update", "--workspace"]);
485
+ }
399
486
 
400
- let (cmd, args) = LockfileType::GoSum.command();
487
+ #[test]
488
+ fn test_gemfile_lock_uses_bundle_lock_update_with_changed_packages() {
489
+ let changed = vec!["rails".to_string(), "pg".to_string()];
490
+ let (cmd, args) = LockfileType::GemfileLock.command(&changed);
491
+ assert_eq!(cmd, "bundle");
492
+ assert_eq!(args, vec!["lock", "--update", "rails", "pg"]);
493
+ }
494
+
495
+ #[test]
496
+ fn test_gemfile_lock_with_empty_changed_list_uses_plain_bundle_lock() {
497
+ // Without targeted packages, `bundle lock --update` would bump every gem;
498
+ // we emit plain `bundle lock` (refreshes against the current Gemfile
499
+ // without bumping anything).
500
+ let (cmd, args) = LockfileType::GemfileLock.command(&[]);
501
+ assert_eq!(cmd, "bundle");
502
+ assert_eq!(args, vec!["lock"]);
503
+ }
504
+
505
+ #[test]
506
+ fn test_go_sum_falls_back_to_mod_tidy_regardless_of_changed_list() {
507
+ let (cmd, args) = LockfileType::GoSum.command(&["golang.org/x/net".to_string()]);
401
508
  assert_eq!(cmd, "go");
402
- assert_eq!(args, &["mod", "tidy"]);
509
+ assert_eq!(args, vec!["mod", "tidy"]);
510
+ }
511
+
512
+ #[test]
513
+ fn test_packages_lock_json_falls_back_to_dotnet_restore() {
514
+ let (cmd, args) = LockfileType::PackagesLockJson.command(&["Newtonsoft.Json".to_string()]);
515
+ assert_eq!(cmd, "dotnet");
516
+ assert_eq!(args, vec!["restore"]);
517
+ }
518
+
519
+ #[test]
520
+ fn test_terraform_lock_falls_back_to_providers_lock() {
521
+ let (cmd, args) = LockfileType::TerraformLock.command(&["hashicorp/aws".to_string()]);
522
+ assert_eq!(cmd, "terraform");
523
+ assert_eq!(args, vec!["providers", "lock"]);
524
+ }
525
+
526
+ #[test]
527
+ fn test_bun_lock_uses_bun_install() {
528
+ // Bun does not have a stable lockfile-only mode; plain `install` is the
529
+ // minimum reliable form. Keeping the test pins the decision so changes
530
+ // here are intentional.
531
+ let (cmd, args) = LockfileType::BunLock.command(&["react".to_string()]);
532
+ assert_eq!(cmd, "bun");
533
+ assert_eq!(args, vec!["install"]);
403
534
  }
404
535
 
405
536
  #[test]
@@ -570,13 +701,6 @@ mod tests {
570
701
  assert_eq!(LockfileType::GemfileLock.filename(), "Gemfile.lock");
571
702
  }
572
703
 
573
- #[test]
574
- fn test_lockfile_type_gemfile_command() {
575
- let (cmd, args) = LockfileType::GemfileLock.command();
576
- assert_eq!(cmd, "bundle");
577
- assert_eq!(args, &["install"]);
578
- }
579
-
580
704
  #[test]
581
705
  fn test_lockfile_type_gemfile_manifest() {
582
706
  assert_eq!(LockfileType::GemfileLock.manifest(), "Gemfile");
@@ -630,16 +754,6 @@ mod tests {
630
754
  );
631
755
  }
632
756
 
633
- #[test]
634
- fn test_lockfile_type_packages_lock_json_command() {
635
- // .NET lockfiles are regenerated via `dotnet restore` (which
636
- // rewrites `packages.lock.json` next to each .csproj when
637
- // `RestorePackagesWithLockFile` is enabled).
638
- let (cmd, args) = LockfileType::PackagesLockJson.command();
639
- assert_eq!(cmd, "dotnet");
640
- assert_eq!(args, &["restore"]);
641
- }
642
-
643
757
  #[test]
644
758
  fn test_detect_lockfiles_packages_lock_json_for_csproj() {
645
759
  let dir = tempdir().unwrap();
@@ -681,17 +795,6 @@ mod tests {
681
795
  );
682
796
  }
683
797
 
684
- #[test]
685
- fn test_lockfile_type_terraform_lock_hcl_command() {
686
- // Terraform's dependency-lock file is regenerated with
687
- // `terraform providers lock -platform=...`. We use the bare form
688
- // which updates the existing lock in-place for all platforms
689
- // currently pinned.
690
- let (cmd, args) = LockfileType::TerraformLock.command();
691
- assert_eq!(cmd, "terraform");
692
- assert_eq!(args, &["providers", "lock"]);
693
- }
694
-
695
798
  #[test]
696
799
  fn test_detect_lockfiles_terraform_lock_hcl_for_tf_file() {
697
800
  let dir = tempdir().unwrap();
@@ -743,7 +846,7 @@ mod tests {
743
846
  fs::write(&manifest, "{}").unwrap();
744
847
  // Deliberately do NOT create package-lock.json
745
848
 
746
- let result = regenerate_lockfiles(&manifest, false);
849
+ let result = regenerate_lockfiles(&manifest, &[], false);
747
850
  assert!(
748
851
  result.no_lockfiles,
749
852
  "no_lockfiles should be true when no lockfile exists beside the manifest"
@@ -762,7 +865,7 @@ mod tests {
762
865
  fs::write(&manifest, "[package]").unwrap();
763
866
  // Deliberately do NOT create Cargo.lock
764
867
 
765
- let result = regenerate_lockfiles(&manifest, false);
868
+ let result = regenerate_lockfiles(&manifest, &[], false);
766
869
  assert!(
767
870
  result.no_lockfiles,
768
871
  "no_lockfiles should be true when Cargo.lock is absent"