1use {
2 anyhow::{anyhow, Context, Result},
3 clap::{Args, ValueEnum},
4 log::{debug, info},
5 semver::Version,
6 std::{
7 collections::{BTreeMap, BTreeSet},
8 fs,
9 path::Path,
10 process::Command,
11 },
12 toml_edit::{value, DocumentMut, Item, Table, Value},
13};
14
15#[derive(Args)]
16pub struct CommandArgs {
17 #[arg(value_enum)]
18 pub level: BumpLevel,
19}
20
21#[derive(ValueEnum, Clone, Debug)]
22pub enum BumpLevel {
23 #[value(help = "Bump major: x.y.z -> x+1.0.0")]
24 Major,
25 #[value(help = "Bump minor: x.y.z -> x.y+1.0")]
26 Minor,
27 #[value(help = "Bump patch: x.y.z -> x.y.z+1")]
28 Patch,
29 #[value(
30 help = "Bump prerelease suffix: x.y.z-<tag>.n -> x.y.z-<tag>.n+1 (e.g. alpha/beta/rc)"
31 )]
32 PreRelease,
33 #[value(
34 help = "Promote prerelease stage: alpha.n -> beta.0, beta.n -> rc.0, rc.n -> '' (removed rc prerelease)"
35 )]
36 PromotePreRelease,
37 #[value(
38 help = "Bump prerelease if present; otherwise bump patch (x.y.z-<tag>.n -> x.y.z-<tag>.n+1, x.y.z -> x.y.z+1)"
39 )]
40 PatchOrPreRelease,
41}
42
43pub fn run(args: CommandArgs) -> Result<()> {
44 let current_version_str =
45 crate::utils::get_current_version().context("failed to get current version")?;
46 let current_version = Version::parse(¤t_version_str)?;
47
48 let new_version = bump_version(&args.level, ¤t_version)?;
49
50 let all_crates = crate::utils::get_all_crates().context("failed to get all crates")?;
51
52 let all_cargo_tomls =
53 crate::utils::find_all_cargo_tomls().context("failed to find all cargo.toml files")?;
54 info!("found {} cargo.toml files", all_cargo_tomls.len());
55 for cargo_toml in all_cargo_tomls {
56 info!("processing {}", cargo_toml.display());
57
58 let content = fs::read_to_string(&cargo_toml)
59 .context(format!("failed to read {}", cargo_toml.display()))?;
60 let mut doc = content
61 .parse::<DocumentMut>()
62 .context(format!("failed to parse {}", cargo_toml.display()))?;
63
64 let original = doc.clone();
65 let mut intended: BTreeMap<String, (String, String)> = BTreeMap::new();
66
67 if let Some(workspace_package_version_str) = doc
68 .get("workspace")
69 .and_then(|workspace| workspace.get("package"))
70 .and_then(|package| package.get("version"))
71 .and_then(|version| version.as_str())
72 {
73 if workspace_package_version_str == current_version.to_string() {
74 doc["workspace"]["package"]["version"] = value(new_version.to_string());
75 intended.insert(
76 "workspace.package.version".to_string(),
77 (current_version.to_string(), new_version.to_string()),
78 );
79 info!(" bumped workspace.package.version from {current_version} to {new_version}",);
80 }
81 }
82
83 if let Some(package_version_str) = doc
84 .get("package")
85 .and_then(|package| package.get("version"))
86 .and_then(|version| version.as_str())
87 {
88 if package_version_str == current_version.to_string() {
89 doc["package"]["version"] = value(new_version.to_string());
90 intended.insert(
91 "package.version".to_string(),
92 (current_version.to_string(), new_version.to_string()),
93 );
94 info!(" bumped package.version from {current_version} to {new_version}",);
95 }
96 }
97
98 if let Some(dependencies) = doc
99 .get("workspace")
100 .and_then(|ws| ws.get("dependencies"))
101 .and_then(|deps| deps.as_table())
102 {
103 let keys: Vec<String> = dependencies.iter().map(|(k, _)| k.to_string()).collect();
105
106 for name in keys {
107 if all_crates.contains(&name) {
108 if let Some(version) = doc["workspace"]["dependencies"]
109 .get(&name)
110 .and_then(|v| v.get("version"))
111 .and_then(|v| v.as_str())
112 {
113 if !version.contains(¤t_version.to_string()) {
114 continue;
115 }
116 let old_version = version.to_string();
117 let bumped_version = old_version
118 .replace(¤t_version.to_string(), &new_version.to_string());
119 doc["workspace"]["dependencies"][&name]["version"] = value(&bumped_version);
120 intended.insert(
121 format!("workspace.dependencies.{name}.version"),
122 (old_version.clone(), bumped_version.clone()),
123 );
124 info!(
125 " bumped workspace.dependencies.{name}.version from {old_version} to \
126 {bumped_version}",
127 );
128 }
129 }
130 }
131 }
132
133 verify_changes(&original, &doc, &intended, &cargo_toml).context(format!(
134 "unexpected changes while bumping {}",
135 cargo_toml.display()
136 ))?;
137
138 debug!("writing {}", cargo_toml.display());
140 fs::write(&cargo_toml, doc.to_string())
141 .context(format!("failed to write {}", cargo_toml.display()))?;
142 }
143
144 let all_cargo_locks =
145 crate::utils::find_all_cargo_locks().context("failed to find all Cargo.lock files")?;
146 info!("found {} Cargo.lock files", all_cargo_locks.len());
147 for cargo_lock in all_cargo_locks {
148 let dir = cargo_lock.parent().context(format!(
149 "failed to get {}'s parent directory",
150 cargo_lock.display()
151 ))?;
152
153 let before = fs::read_to_string(&cargo_lock)
154 .context(format!("failed to read {}", cargo_lock.display()))?;
155
156 info!("running `cargo tree` in {}", dir.display());
157 let output = Command::new("cargo")
158 .arg("tree")
159 .current_dir(dir)
160 .output()
161 .context(format!("failed to run `cargo tree` in {}", dir.display()))?;
162 if !output.status.success() {
163 return Err(anyhow!("{}", String::from_utf8_lossy(&output.stderr)));
164 }
165
166 let after = fs::read_to_string(&cargo_lock)
167 .context(format!("failed to read {}", cargo_lock.display()))?;
168
169 verify_lock_changes(
170 &before,
171 &after,
172 &all_crates,
173 ¤t_version,
174 &new_version,
175 &cargo_lock,
176 )
177 .context(format!(
178 "unexpected changes while bumping {}",
179 cargo_lock.display()
180 ))?;
181 }
182
183 Ok(())
184}
185
186fn verify_changes(
187 original: &DocumentMut,
188 modified: &DocumentMut,
189 intended: &BTreeMap<String, (String, String)>,
190 file: &Path,
191) -> Result<()> {
192 let before = flatten_leaves(original);
193 let after = flatten_leaves(modified);
194
195 let mut actual: BTreeMap<String, (String, String)> = BTreeMap::new();
196 let paths: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
197 for path in paths {
198 let old = before.get(path);
199 let new = after.get(path);
200 if old != new {
201 actual.insert(
202 path.clone(),
203 (
204 old.cloned().unwrap_or_default(),
205 new.cloned().unwrap_or_default(),
206 ),
207 );
208 }
209 }
210
211 if &actual == intended {
212 return Ok(());
213 }
214
215 let mut errors = vec![];
216 for (path, (old, new)) in &actual {
217 match intended.get(path) {
218 None => errors.push(format!(
219 " unexpected change at `{path}`: {old:?} -> {new:?}"
220 )),
221 Some(expected) if expected != &(old.clone(), new.clone()) => errors.push(format!(
222 " wrong change at `{path}`: expected {expected:?}, got {:?}",
223 (old, new)
224 )),
225 _ => {}
226 }
227 }
228 for path in intended.keys() {
229 if !actual.contains_key(path) {
230 errors.push(format!(" expected change at `{path}` did not happen"));
231 }
232 }
233
234 Err(anyhow!(
235 "version bump touched unexpected content in {}:\n{}",
236 file.display(),
237 errors.join("\n")
238 ))
239}
240
241fn verify_lock_changes(
242 before: &str,
243 after: &str,
244 all_crates: &[String],
245 current: &Version,
246 new: &Version,
247 file: &Path,
248) -> Result<()> {
249 let before_pkgs = parse_lock_packages(before)
250 .context(format!("failed to parse {} before bump", file.display()))?;
251 let after_pkgs = parse_lock_packages(after)
252 .context(format!("failed to parse {} after bump", file.display()))?;
253
254 let crates: BTreeSet<&str> = all_crates.iter().map(String::as_str).collect();
255 let current = current.to_string();
256 let new = new.to_string();
257
258 let mut expected: BTreeMap<(String, String), String> = BTreeMap::new();
261 for ((name, version), body) in &before_pkgs {
262 let key = if crates.contains(name.as_str()) && version == ¤t {
263 (name.clone(), new.clone())
264 } else {
265 (name.clone(), version.clone())
266 };
267 expected.insert(key, body.clone());
268 }
269
270 if expected == after_pkgs {
271 return Ok(());
272 }
273
274 let mut errors = vec![];
275 let keys: BTreeSet<&(String, String)> = expected.keys().chain(after_pkgs.keys()).collect();
276 for key @ (name, version) in keys {
277 match (expected.get(key), after_pkgs.get(key)) {
278 (Some(want), Some(got)) if want != got => {
279 errors.push(format!(" unexpected change to package `{name} {version}`"));
280 }
281 (Some(_), None) => {
282 errors.push(format!(" missing package `{name} {version}` after bump"));
283 }
284 (None, Some(_)) => {
285 errors.push(format!(" unexpected package `{name} {version}`"));
286 }
287 _ => {}
288 }
289 }
290
291 Err(anyhow!(
292 "version bump touched unexpected content in {}:\n{}",
293 file.display(),
294 errors.join("\n")
295 ))
296}
297
298fn parse_lock_packages(content: &str) -> Result<BTreeMap<(String, String), String>> {
299 let doc = content.parse::<DocumentMut>()?;
300
301 let mut packages = BTreeMap::new();
302 if let Some(Item::ArrayOfTables(entries)) = doc.get("package") {
303 for entry in entries.iter() {
304 let name = entry.get("name").and_then(Item::as_str);
305 let version = entry.get("version").and_then(Item::as_str);
306 let (Some(name), Some(version)) = (name, version) else {
307 continue;
308 };
309
310 let mut fields = vec![];
311 for (key, item) in entry.iter() {
312 if key == "name" || key == "version" {
313 continue;
314 }
315 fields.push(format!("{key}={}", item.to_string().trim()));
316 }
317 fields.sort();
318
319 packages.insert((name.to_string(), version.to_string()), fields.join("\n"));
320 }
321 }
322
323 Ok(packages)
324}
325
326fn flatten_leaves(doc: &DocumentMut) -> BTreeMap<String, String> {
327 let mut out = BTreeMap::new();
328 walk_table(doc.as_table(), String::new(), &mut out);
329 out
330}
331
332fn walk_table(table: &Table, prefix: String, out: &mut BTreeMap<String, String>) {
333 for (key, item) in table.iter() {
334 walk_item(item, join(&prefix, key), out);
335 }
336}
337
338fn walk_item(item: &Item, path: String, out: &mut BTreeMap<String, String>) {
339 match item {
340 Item::Value(v) => walk_value(v, path, out),
341 Item::Table(t) => walk_table(t, path, out),
342 Item::ArrayOfTables(arr) => {
343 for (i, t) in arr.iter().enumerate() {
344 walk_table(t, format!("{path}[{i}]"), out);
345 }
346 }
347 Item::None => {}
348 }
349}
350
351fn walk_value(v: &Value, path: String, out: &mut BTreeMap<String, String>) {
352 match v {
353 Value::InlineTable(t) => {
354 for (key, val) in t.iter() {
355 walk_value(val, join(&path, key), out);
356 }
357 }
358 Value::Array(arr) => {
359 for (i, val) in arr.iter().enumerate() {
360 walk_value(val, format!("{path}[{i}]"), out);
361 }
362 }
363 scalar => {
364 let repr = scalar
365 .as_str()
366 .map(str::to_string)
367 .unwrap_or_else(|| scalar.to_string().trim().to_string());
368 out.insert(path, repr);
369 }
370 }
371}
372
373fn join(prefix: &str, key: &str) -> String {
374 if prefix.is_empty() {
375 key.to_string()
376 } else {
377 format!("{prefix}.{key}")
378 }
379}
380
381pub fn bump_version(level: &BumpLevel, current: &Version) -> Result<Version> {
382 let mut new_version = current.clone();
383 match level {
384 BumpLevel::Major => {
385 new_version.major = new_version.major.saturating_add(1);
386 new_version.minor = 0;
387 new_version.patch = 0;
388 }
389 BumpLevel::Minor => {
390 new_version.minor = new_version.minor.saturating_add(1);
391 new_version.patch = 0;
392 }
393 BumpLevel::Patch => {
394 new_version.patch = new_version.patch.saturating_add(1);
395 }
396 BumpLevel::PreRelease => {
397 if let Some((prefix, number_str)) = current.pre.as_str().split_once('.') {
398 if let Ok(number) = number_str.parse::<u64>() {
399 let next = number.saturating_add(1);
400 if let Ok(next_pre) = semver::Prerelease::new(&format!("{prefix}.{next}")) {
401 new_version.pre = next_pre;
402 }
403 } else {
404 return Err(anyhow!("unexpected prerelease format: {}", current.pre));
405 }
406 } else {
407 return Err(anyhow!("unexpected prerelease format: {}", current.pre));
408 }
409 }
410 BumpLevel::PromotePreRelease => {
411 if let Some((prefix, _)) = current.pre.as_str().split_once('.') {
412 match prefix {
413 "alpha" => {
414 new_version.pre = semver::Prerelease::new("beta.0").unwrap();
415 }
416 "beta" => {
417 new_version.pre = semver::Prerelease::new("rc.0").unwrap();
418 }
419 "rc" => {
420 new_version.pre = semver::Prerelease::new("").unwrap();
421 }
422 _ => {
423 return Err(anyhow!("unexpected prerelease format: {}, only alpha, beta, and rc are supported", current.pre));
424 }
425 }
426 } else {
427 return Err(anyhow!("unexpected prerelease format: {}", current.pre));
428 }
429 }
430 BumpLevel::PatchOrPreRelease => {
431 if current.pre.is_empty() {
432 new_version = bump_version(&BumpLevel::Patch, current)?;
433 } else {
434 new_version = bump_version(&BumpLevel::PreRelease, current)?;
435 }
436 }
437 }
438
439 Ok(new_version)
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn test_bump_version_major() {
448 assert_eq!(
449 bump_version(&BumpLevel::Major, &Version::parse("1.0.0").unwrap()).unwrap(),
450 Version::parse("2.0.0").unwrap()
451 );
452
453 assert_eq!(
454 bump_version(&BumpLevel::Major, &Version::parse("1.1.0").unwrap()).unwrap(),
455 Version::parse("2.0.0").unwrap()
456 );
457
458 assert_eq!(
459 bump_version(&BumpLevel::Major, &Version::parse("1.1.1").unwrap()).unwrap(),
460 Version::parse("2.0.0").unwrap()
461 );
462 }
463 #[test]
464 fn test_bump_version_minor() {
465 assert_eq!(
466 bump_version(&BumpLevel::Minor, &Version::parse("1.0.0").unwrap()).unwrap(),
467 Version::parse("1.1.0").unwrap()
468 );
469
470 assert_eq!(
471 bump_version(&BumpLevel::Minor, &Version::parse("1.2.1").unwrap()).unwrap(),
472 Version::parse("1.3.0").unwrap()
473 );
474 }
475
476 #[test]
477 fn test_bump_version_patch() {
478 assert_eq!(
479 bump_version(&BumpLevel::Patch, &Version::parse("1.0.0").unwrap()).unwrap(),
480 Version::parse("1.0.1").unwrap()
481 );
482 }
483
484 #[test]
485 fn test_bump_version_prerelease() {
486 assert_eq!(
487 bump_version(
488 &BumpLevel::PreRelease,
489 &Version::parse("1.2.3-alpha.0").unwrap()
490 )
491 .unwrap(),
492 Version::parse("1.2.3-alpha.1").unwrap()
493 );
494 assert_eq!(
495 bump_version(
496 &BumpLevel::PreRelease,
497 &Version::parse("1.2.3-alpha.1").unwrap()
498 )
499 .unwrap(),
500 Version::parse("1.2.3-alpha.2").unwrap()
501 );
502 assert_eq!(
503 bump_version(
504 &BumpLevel::PreRelease,
505 &Version::parse("1.2.3-beta.0").unwrap()
506 )
507 .unwrap(),
508 Version::parse("1.2.3-beta.1").unwrap()
509 );
510 assert_eq!(
511 bump_version(
512 &BumpLevel::PreRelease,
513 &Version::parse("1.2.3-rc.0").unwrap()
514 )
515 .unwrap(),
516 Version::parse("1.2.3-rc.1").unwrap()
517 );
518
519 assert_eq!(
520 bump_version(
521 &BumpLevel::PreRelease,
522 &Version::parse("1.2.3-alpha123").unwrap()
523 )
524 .unwrap_err()
525 .to_string(),
526 "unexpected prerelease format: alpha123",
527 );
528
529 assert_eq!(
530 bump_version(
531 &BumpLevel::PreRelease,
532 &Version::parse("1.2.3-alpha.custom").unwrap()
533 )
534 .unwrap_err()
535 .to_string(),
536 "unexpected prerelease format: alpha.custom",
537 );
538 }
539
540 #[test]
541 fn test_bump_version_promote_prerelease() {
542 assert_eq!(
543 bump_version(
544 &BumpLevel::PromotePreRelease,
545 &Version::parse("1.2.3-alpha.0").unwrap()
546 )
547 .unwrap(),
548 Version::parse("1.2.3-beta.0").unwrap()
549 );
550
551 assert_eq!(
552 bump_version(
553 &BumpLevel::PromotePreRelease,
554 &Version::parse("1.2.3-alpha.1").unwrap()
555 )
556 .unwrap(),
557 Version::parse("1.2.3-beta.0").unwrap()
558 );
559
560 assert_eq!(
561 bump_version(
562 &BumpLevel::PromotePreRelease,
563 &Version::parse("1.2.3-beta.0").unwrap()
564 )
565 .unwrap(),
566 Version::parse("1.2.3-rc.0").unwrap()
567 );
568
569 assert_eq!(
570 bump_version(
571 &BumpLevel::PromotePreRelease,
572 &Version::parse("1.2.3-rc.0").unwrap()
573 )
574 .unwrap(),
575 Version::parse("1.2.3").unwrap()
576 );
577
578 assert_eq!(
579 bump_version(
580 &BumpLevel::PromotePreRelease,
581 &Version::parse("1.2.3-alpha123").unwrap()
582 )
583 .unwrap_err()
584 .to_string(),
585 "unexpected prerelease format: alpha123",
586 );
587
588 assert_eq!(
589 bump_version(
590 &BumpLevel::PromotePreRelease,
591 &Version::parse("1.2.3-custom.1").unwrap()
592 )
593 .unwrap_err()
594 .to_string(),
595 "unexpected prerelease format: custom.1, only alpha, beta, and rc are supported"
596 );
597 }
598
599 #[test]
600 fn test_bump_version_patch_or_prerelease() {
601 assert_eq!(
602 bump_version(
603 &BumpLevel::PatchOrPreRelease,
604 &Version::parse("1.2.3-alpha.0").unwrap()
605 )
606 .unwrap(),
607 Version::parse("1.2.3-alpha.1").unwrap()
608 );
609 assert_eq!(
610 bump_version(
611 &BumpLevel::PatchOrPreRelease,
612 &Version::parse("1.2.3").unwrap()
613 )
614 .unwrap(),
615 Version::parse("1.2.4").unwrap()
616 );
617 }
618
619 fn doc(s: &str) -> DocumentMut {
620 s.parse().unwrap()
621 }
622
623 fn intent(pairs: &[(&str, &str, &str)]) -> BTreeMap<String, (String, String)> {
624 pairs
625 .iter()
626 .map(|(p, o, n)| (p.to_string(), (o.to_string(), n.to_string())))
627 .collect()
628 }
629
630 #[test]
631 fn test_verify_changes_ok() {
632 let original = doc("[package]\nname = \"foo\"\nversion = \"1.0.0\"\n");
633 let modified = doc("[package]\nname = \"foo\"\nversion = \"1.1.0\"\n");
634 let intended = intent(&[("package.version", "1.0.0", "1.1.0")]);
635 assert!(verify_changes(&original, &modified, &intended, Path::new("Cargo.toml")).is_ok());
636 }
637
638 #[test]
639 fn test_verify_changes_ignores_formatting() {
640 let original = doc("[package]\nname = \"foo\"\nversion = \"1.0.0\"\n");
641 let modified = doc("[package]\n# comment\nname = \"foo\"\nversion = \"1.0.0\"\n");
642 assert!(verify_changes(
643 &original,
644 &modified,
645 &BTreeMap::new(),
646 Path::new("Cargo.toml")
647 )
648 .is_ok());
649 }
650
651 #[test]
652 fn test_verify_changes_detects_stray_edit() {
653 let original = doc("[package]\nname = \"foo\"\nversion = \"1.0.0\"\n");
654 let modified = doc("[package]\nname = \"bar\"\nversion = \"1.1.0\"\n");
655 let intended = intent(&[("package.version", "1.0.0", "1.1.0")]);
656 let err = verify_changes(&original, &modified, &intended, Path::new("Cargo.toml"))
657 .unwrap_err()
658 .to_string();
659 assert!(err.contains("unexpected change at `package.name`"), "{err}");
660 }
661
662 #[test]
663 fn test_verify_changes_detects_skipped_bump() {
664 let original = doc("[package]\nversion = \"1.0.0\"\n");
665 let modified = original.clone();
666 let intended = intent(&[("package.version", "1.0.0", "1.1.0")]);
667 let err = verify_changes(&original, &modified, &intended, Path::new("Cargo.toml"))
668 .unwrap_err()
669 .to_string();
670 assert!(
671 err.contains("expected change at `package.version` did not happen"),
672 "{err}"
673 );
674 }
675
676 #[test]
677 fn test_verify_changes_detects_wrong_value() {
678 let original = doc("[package]\nversion = \"1.0.0\"\n");
679 let modified = doc("[package]\nversion = \"2.0.0\"\n");
680 let intended = intent(&[("package.version", "1.0.0", "1.1.0")]);
681 let err = verify_changes(&original, &modified, &intended, Path::new("Cargo.toml"))
682 .unwrap_err()
683 .to_string();
684 assert!(err.contains("wrong change at `package.version`"), "{err}");
685 }
686
687 fn lock(packages: &[(&str, &str)]) -> String {
688 let mut out = String::from("version = 3\n");
689 for (name, version) in packages {
690 out.push_str(&format!(
691 "\n[[package]]\nname = \"{name}\"\nversion = \"{version}\"\n"
692 ));
693 }
694 out
695 }
696
697 fn crates(names: &[&str]) -> Vec<String> {
698 names.iter().map(|s| s.to_string()).collect()
699 }
700
701 #[test]
702 fn test_verify_lock_changes_ok() {
703 let before = lock(&[("foo", "1.0.0"), ("serde", "1.0.150")]);
704 let after = lock(&[("foo", "1.1.0"), ("serde", "1.0.150")]);
705 assert!(verify_lock_changes(
706 &before,
707 &after,
708 &crates(&["foo"]),
709 &Version::parse("1.0.0").unwrap(),
710 &Version::parse("1.1.0").unwrap(),
711 Path::new("Cargo.lock"),
712 )
713 .is_ok());
714 }
715
716 #[test]
717 fn test_verify_lock_changes_detects_transitive_jump() {
718 let before = lock(&[("foo", "1.0.0"), ("serde", "1.0.150")]);
719 let after = lock(&[("foo", "1.1.0"), ("serde", "1.0.200")]);
720 let err = verify_lock_changes(
721 &before,
722 &after,
723 &crates(&["foo"]),
724 &Version::parse("1.0.0").unwrap(),
725 &Version::parse("1.1.0").unwrap(),
726 Path::new("Cargo.lock"),
727 )
728 .unwrap_err()
729 .to_string();
730 assert!(err.contains("unexpected package `serde 1.0.200`"), "{err}");
731 assert!(
732 err.contains("missing package `serde 1.0.150` after bump"),
733 "{err}"
734 );
735 }
736
737 #[test]
738 fn test_verify_lock_changes_detects_new_package() {
739 let before = lock(&[("foo", "1.0.0")]);
740 let after = lock(&[("foo", "1.1.0"), ("newdep", "0.1.0")]);
741 let err = verify_lock_changes(
742 &before,
743 &after,
744 &crates(&["foo"]),
745 &Version::parse("1.0.0").unwrap(),
746 &Version::parse("1.1.0").unwrap(),
747 Path::new("Cargo.lock"),
748 )
749 .unwrap_err()
750 .to_string();
751 assert!(err.contains("unexpected package `newdep 0.1.0`"), "{err}");
752 }
753
754 #[test]
755 fn test_verify_lock_changes_detects_skipped_bump() {
756 let before = lock(&[("foo", "1.0.0")]);
757 let after = before.clone();
758 let err = verify_lock_changes(
759 &before,
760 &after,
761 &crates(&["foo"]),
762 &Version::parse("1.0.0").unwrap(),
763 &Version::parse("1.1.0").unwrap(),
764 Path::new("Cargo.lock"),
765 )
766 .unwrap_err()
767 .to_string();
768 assert!(
769 err.contains("missing package `foo 1.1.0` after bump"),
770 "{err}"
771 );
772 assert!(err.contains("unexpected package `foo 1.0.0`"), "{err}");
773 }
774
775 #[test]
776 fn test_verify_lock_changes_detects_moved_dependency_edge() {
777 let before = "version = 3\n\n[[package]]\nname = \"foo\"\nversion = \"1.0.0\"\n\n[[package]]\nname = \"tokio\"\nversion = \"1.52.3\"\ndependencies = [\"windows-sys 0.61.0\"]\n\n[[package]]\nname = \"windows-sys\"\nversion = \"0.45.0\"\n\n[[package]]\nname = \"windows-sys\"\nversion = \"0.61.0\"\n";
778 let after = "version = 3\n\n[[package]]\nname = \"foo\"\nversion = \"1.1.0\"\n\n[[package]]\nname = \"tokio\"\nversion = \"1.52.3\"\ndependencies = [\"windows-sys 0.45.0\"]\n\n[[package]]\nname = \"windows-sys\"\nversion = \"0.45.0\"\n\n[[package]]\nname = \"windows-sys\"\nversion = \"0.61.0\"\n";
779 let err = verify_lock_changes(
780 before,
781 after,
782 &crates(&["foo"]),
783 &Version::parse("1.0.0").unwrap(),
784 &Version::parse("1.1.0").unwrap(),
785 Path::new("Cargo.lock"),
786 )
787 .unwrap_err()
788 .to_string();
789 assert!(
790 err.contains("unexpected change to package `tokio 1.52.3`"),
791 "{err}"
792 );
793 }
794
795 #[test]
796 fn test_verify_lock_changes_ignores_non_workspace_crate() {
797 let before = lock(&[("foo", "1.0.0"), ("other", "1.0.0")]);
800 let after = lock(&[("foo", "1.1.0"), ("other", "1.0.0")]);
801 assert!(verify_lock_changes(
802 &before,
803 &after,
804 &crates(&["foo"]),
805 &Version::parse("1.0.0").unwrap(),
806 &Version::parse("1.1.0").unwrap(),
807 Path::new("Cargo.lock"),
808 )
809 .is_ok());
810 }
811}