Skip to main content

anza_xtask/commands/
bump_version.rs

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(&current_version_str)?;
47
48    let new_version = bump_version(&args.level, &current_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            // Avoid borrowing `doc` while iterating
104            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(&current_version.to_string()) {
114                            continue;
115                        }
116                        let old_version = version.to_string();
117                        let bumped_version = old_version
118                            .replace(&current_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        // write the updated document back to the file
139        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            &current_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    // Rebuild the lock we expect: workspace crates move current -> new, every
259    // other package (source, checksum, dependency edges) stays identical.
260    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 == &current {
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        // A crate that shares the old version string but isn't a workspace member
798        // must not be touched, and its presence must not be required to change.
799        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}