cargo_release/ops/
cargo.rs

1use std::env;
2use std::path::Path;
3
4use bstr::ByteSlice;
5use itertools::Itertools as _;
6
7use crate::config::{self, CertsSource};
8use crate::error::CargoResult;
9use crate::ops::cmd::call;
10
11/// Expresses what features flags should be used
12#[derive(Clone, Debug)]
13pub enum Features {
14    /// None - don't use special features
15    None,
16    /// Only use selected features
17    Selective(Vec<String>),
18    /// Use all features via `all-features`
19    All,
20}
21
22fn cargo() -> String {
23    env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned())
24}
25
26pub fn package_content(manifest_path: &Path) -> CargoResult<Vec<std::path::PathBuf>> {
27    let mut cmd = std::process::Command::new(cargo());
28    cmd.arg("package");
29    cmd.arg("--manifest-path");
30    cmd.arg(manifest_path);
31    cmd.arg("--list");
32    // Not worth passing around allow_dirty to here since we are just getting a file list.
33    cmd.arg("--allow-dirty");
34    let output = cmd.output()?;
35
36    let parent = manifest_path.parent().unwrap_or_else(|| Path::new(""));
37
38    if output.status.success() {
39        let paths = ByteSlice::lines(output.stdout.as_slice())
40            .map(|l| parent.join(l.to_path_lossy()))
41            .collect();
42        Ok(paths)
43    } else {
44        let error = String::from_utf8_lossy(&output.stderr);
45        Err(anyhow::format_err!(
46            "failed to get package content for {}: {}",
47            manifest_path.display(),
48            error
49        ))
50    }
51}
52
53pub fn publish(
54    dry_run: bool,
55    verify: bool,
56    manifest_path: &Path,
57    pkgids: &[&str],
58    features: &[&Features],
59    registry: Option<&str>,
60    target: Option<&str>,
61) -> CargoResult<bool> {
62    if pkgids.is_empty() {
63        return Ok(true);
64    }
65
66    let cargo = cargo();
67
68    let mut command: Vec<&str> = vec![
69        &cargo,
70        "publish",
71        "--manifest-path",
72        manifest_path.to_str().unwrap(),
73    ];
74
75    if 1 < pkgids.len() {
76        command.push("-Zpackage-workspace");
77    }
78    for pkgid in pkgids {
79        command.push("--package");
80        command.push(pkgid);
81    }
82
83    if let Some(registry) = registry {
84        command.push("--registry");
85        command.push(registry);
86    }
87
88    if dry_run {
89        command.push("--dry-run");
90        command.push("--allow-dirty");
91    }
92
93    if !verify {
94        command.push("--no-verify");
95    }
96
97    if let Some(target) = target {
98        command.push("--target");
99        command.push(target);
100    }
101
102    if features.iter().any(|f| matches!(f, Features::None)) {
103        command.push("--no-default-features");
104    }
105    if features.iter().any(|f| matches!(f, Features::All)) {
106        command.push("--all-features");
107    }
108    let selective = features
109        .iter()
110        .filter_map(|f| {
111            if let Features::Selective(f) = f {
112                Some(f)
113            } else {
114                None
115            }
116        })
117        .flatten()
118        .join(",");
119    if !selective.is_empty() {
120        command.push("--features");
121        command.push(&selective);
122    }
123
124    call(command, false)
125}
126
127pub fn is_published(
128    index: &mut crate::ops::index::CratesIoIndex,
129    registry: Option<&str>,
130    name: &str,
131    version: &str,
132    certs_source: CertsSource,
133) -> bool {
134    match index.has_krate_version(registry, name, version, certs_source) {
135        Ok(has_krate_version) => has_krate_version.unwrap_or(false),
136        Err(err) => {
137            // For both http and git indices, this _might_ be an error that goes away in
138            // a future call, but at least printing out something should give the user
139            // an indication something is amiss
140            log::warn!("failed to read metadata for {name}: {err:#}");
141            false
142        }
143    }
144}
145
146pub fn set_workspace_version(
147    manifest_path: &Path,
148    version: &str,
149    dry_run: bool,
150) -> CargoResult<()> {
151    let original_manifest = std::fs::read_to_string(manifest_path)?;
152    let mut manifest: toml_edit::DocumentMut = original_manifest.parse()?;
153    manifest["workspace"]["package"]["version"] = toml_edit::value(version);
154    let manifest = manifest.to_string();
155
156    if dry_run {
157        if manifest != original_manifest {
158            let diff = crate::ops::diff::unified_diff(
159                &original_manifest,
160                &manifest,
161                manifest_path,
162                "updated",
163            );
164            log::debug!("change:\n{diff}");
165        }
166    } else {
167        atomic_write(manifest_path, &manifest)?;
168    }
169
170    Ok(())
171}
172
173pub fn ensure_owners(
174    name: &str,
175    logins: &[String],
176    registry: Option<&str>,
177    dry_run: bool,
178) -> CargoResult<()> {
179    let cargo = cargo();
180
181    // "Look-before-you-leap" in case the user has permission to publish but not set owners.
182    let mut cmd = std::process::Command::new(&cargo);
183    cmd.arg("owner").arg(name).arg("--color=never");
184    cmd.arg("--list");
185    if let Some(registry) = registry {
186        cmd.arg("--registry");
187        cmd.arg(registry);
188    }
189    let output = cmd.output()?;
190    if !output.status.success() {
191        anyhow::bail!(
192            "failed talking to registry about crate owners: {}",
193            String::from_utf8_lossy(&output.stderr)
194        );
195    }
196    let raw = String::from_utf8(output.stdout)
197        .map_err(|_| anyhow::format_err!("unrecognized response from registry"))?;
198
199    let mut current = std::collections::BTreeSet::new();
200    // HACK: No programmatic CLI access and don't want to link against `cargo` (yet), so parsing
201    // text output
202    for line in raw.lines() {
203        if let Some((owner, _)) = line.split_once(' ')
204            && !owner.is_empty()
205        {
206            current.insert(owner);
207        }
208    }
209
210    let expected = logins
211        .iter()
212        .map(|s| s.as_str())
213        .collect::<std::collections::BTreeSet<_>>();
214
215    let missing = expected.difference(&current).copied().collect::<Vec<_>>();
216    if !missing.is_empty() {
217        let _ = crate::ops::shell::status(
218            "Adding",
219            format!("owners for {}: {}", name, missing.join(", ")),
220        );
221        if !dry_run {
222            let mut cmd = std::process::Command::new(&cargo);
223            cmd.arg("owner").arg(name).arg("--color=never");
224            for missing in missing {
225                cmd.arg("--add").arg(missing);
226            }
227            if let Some(registry) = registry {
228                cmd.arg("--registry");
229                cmd.arg(registry);
230            }
231            let output = cmd.output()?;
232            if !output.status.success() {
233                // HACK: Can't error as the user might not have permission to set owners and we can't
234                // tell what the error was without parsing it
235                let _ = crate::ops::shell::warn(format!(
236                    "failed to set owners for {}: {}",
237                    name,
238                    String::from_utf8_lossy(&output.stderr)
239                ));
240            }
241        }
242    }
243
244    let extra = current.difference(&expected).copied().collect::<Vec<_>>();
245    if !extra.is_empty() {
246        log::debug!("extra owners for {}: {}", name, extra.join(", "));
247    }
248
249    Ok(())
250}
251
252pub fn set_package_version(manifest_path: &Path, version: &str, dry_run: bool) -> CargoResult<()> {
253    let original_manifest = std::fs::read_to_string(manifest_path)?;
254    let mut manifest: toml_edit::DocumentMut = original_manifest.parse()?;
255    manifest["package"]["version"] = toml_edit::value(version);
256    let manifest = manifest.to_string();
257
258    if dry_run {
259        if manifest != original_manifest {
260            let diff = crate::ops::diff::unified_diff(
261                &original_manifest,
262                &manifest,
263                manifest_path,
264                "updated",
265            );
266            log::debug!("change:\n{diff}");
267        }
268    } else {
269        atomic_write(manifest_path, &manifest)?;
270    }
271
272    Ok(())
273}
274
275pub fn upgrade_dependency_req(
276    manifest_name: &str,
277    manifest_path: &Path,
278    root: &Path,
279    name: &str,
280    version: &semver::Version,
281    upgrade: config::DependentVersion,
282    dry_run: bool,
283) -> CargoResult<()> {
284    let manifest_root = manifest_path
285        .parent()
286        .expect("always at least a parent dir");
287    let original_manifest = std::fs::read_to_string(manifest_path)?;
288    let mut manifest: toml_edit::DocumentMut = original_manifest.parse()?;
289
290    for dep_item in find_dependency_tables(manifest.as_table_mut())
291        .flat_map(|t| t.iter_mut().filter_map(|(_, d)| d.as_table_like_mut()))
292        .filter(|d| is_relevant(*d, manifest_root, root))
293    {
294        upgrade_req(manifest_name, dep_item, name, version, upgrade);
295    }
296
297    let manifest = manifest.to_string();
298    if manifest != original_manifest {
299        if dry_run {
300            let diff = crate::ops::diff::unified_diff(
301                &original_manifest,
302                &manifest,
303                manifest_path,
304                "updated",
305            );
306            log::debug!("change:\n{diff}");
307        } else {
308            atomic_write(manifest_path, &manifest)?;
309        }
310    }
311
312    Ok(())
313}
314
315fn find_dependency_tables(
316    root: &mut toml_edit::Table,
317) -> impl Iterator<Item = &mut dyn toml_edit::TableLike> + '_ {
318    const DEP_TABLES: &[&str] = &["dependencies", "dev-dependencies", "build-dependencies"];
319
320    root.iter_mut().flat_map(|(k, v)| {
321        if DEP_TABLES.contains(&k.get()) {
322            v.as_table_like_mut().into_iter().collect::<Vec<_>>()
323        } else if k == "workspace" {
324            v.as_table_like_mut()
325                .unwrap()
326                .iter_mut()
327                .filter_map(|(k, v)| {
328                    if k.get() == "dependencies" {
329                        v.as_table_like_mut()
330                    } else {
331                        None
332                    }
333                })
334                .collect::<Vec<_>>()
335        } else if k == "target" {
336            v.as_table_like_mut()
337                .unwrap()
338                .iter_mut()
339                .flat_map(|(_, v)| {
340                    v.as_table_like_mut().into_iter().flat_map(|v| {
341                        v.iter_mut().filter_map(|(k, v)| {
342                            if DEP_TABLES.contains(&k.get()) {
343                                v.as_table_like_mut()
344                            } else {
345                                None
346                            }
347                        })
348                    })
349                })
350                .collect::<Vec<_>>()
351        } else {
352            Vec::new()
353        }
354    })
355}
356
357fn is_relevant(d: &dyn toml_edit::TableLike, dep_crate_root: &Path, crate_root: &Path) -> bool {
358    if !d.contains_key("version") {
359        return false;
360    }
361    match d
362        .get("path")
363        .and_then(|i| i.as_str())
364        .and_then(|relpath| dunce::canonicalize(dep_crate_root.join(relpath)).ok())
365    {
366        Some(dep_path) => dep_path == crate_root,
367        None => false,
368    }
369}
370
371fn upgrade_req(
372    manifest_name: &str,
373    dep_item: &mut dyn toml_edit::TableLike,
374    name: &str,
375    version: &semver::Version,
376    upgrade: config::DependentVersion,
377) -> bool {
378    let version_value = if let Some(version_value) = dep_item.get_mut("version") {
379        version_value
380    } else {
381        log::debug!("not updating path-only dependency on {name}");
382        return false;
383    };
384
385    let existing_req_str = if let Some(existing_req) = version_value.as_str() {
386        existing_req
387    } else {
388        log::debug!("unsupported dependency {name}");
389        return false;
390    };
391    let Ok(existing_req) = semver::VersionReq::parse(existing_req_str) else {
392        log::debug!("unsupported dependency req {name}={existing_req_str}");
393        return false;
394    };
395    let new_req = match upgrade {
396        config::DependentVersion::Fix => {
397            if !existing_req.matches(version) {
398                let new_req = crate::ops::version::upgrade_requirement(existing_req_str, version)
399                    .ok()
400                    .flatten();
401                if let Some(new_req) = new_req {
402                    new_req
403                } else {
404                    return false;
405                }
406            } else {
407                return false;
408            }
409        }
410        config::DependentVersion::Upgrade => {
411            let new_req = crate::ops::version::upgrade_requirement(existing_req_str, version)
412                .ok()
413                .flatten();
414            if let Some(new_req) = new_req {
415                new_req
416            } else {
417                return false;
418            }
419        }
420    };
421
422    let _ = crate::ops::shell::status(
423        "Updating",
424        format!("{manifest_name}'s dependency from {existing_req_str} to {new_req}"),
425    );
426    *version_value = toml_edit::value(new_req);
427    true
428}
429
430pub fn update_lock(manifest_path: &Path) -> CargoResult<()> {
431    cargo_metadata::MetadataCommand::new()
432        .manifest_path(manifest_path)
433        .exec()?;
434
435    Ok(())
436}
437
438pub fn sort_workspace(ws_meta: &cargo_metadata::Metadata) -> Vec<&cargo_metadata::PackageId> {
439    let members: std::collections::HashSet<_> = ws_meta.workspace_members.iter().collect();
440    let dep_tree: std::collections::HashMap<_, _> = ws_meta
441        .resolve
442        .as_ref()
443        .expect("cargo-metadata resolved deps")
444        .nodes
445        .iter()
446        .filter_map(|n| {
447            if members.contains(&n.id) {
448                // Ignore dev dependencies. This breaks dev dependency cycles and allows for
449                // correct publishing order when a workspace package depends on the root package.
450
451                // It would be more correct to ignore only dev dependencies without a version
452                // field specified. However, cargo_metadata exposes only the resolved version of
453                // a package, and not what semver range (if any) is requested in Cargo.toml.
454
455                let non_dev_pkgs = n.deps.iter().filter_map(|dep| {
456                    let dev_only = dep
457                        .dep_kinds
458                        .iter()
459                        .all(|info| info.kind == cargo_metadata::DependencyKind::Development);
460
461                    if dev_only { None } else { Some(&dep.pkg) }
462                });
463
464                Some((&n.id, non_dev_pkgs.collect()))
465            } else {
466                None
467            }
468        })
469        .collect();
470
471    let mut sorted = Vec::new();
472    let mut processed = std::collections::HashSet::new();
473    for pkg_id in ws_meta.workspace_members.iter() {
474        sort_workspace_inner(pkg_id, &dep_tree, &mut processed, &mut sorted);
475    }
476
477    sorted
478}
479
480fn sort_workspace_inner<'m>(
481    pkg_id: &'m cargo_metadata::PackageId,
482    dep_tree: &std::collections::HashMap<
483        &'m cargo_metadata::PackageId,
484        Vec<&'m cargo_metadata::PackageId>,
485    >,
486    processed: &mut std::collections::HashSet<&'m cargo_metadata::PackageId>,
487    sorted: &mut Vec<&'m cargo_metadata::PackageId>,
488) {
489    if !processed.insert(pkg_id) {
490        return;
491    }
492
493    for dep_id in dep_tree[pkg_id]
494        .iter()
495        .filter(|dep_id| dep_tree.contains_key(*dep_id))
496    {
497        sort_workspace_inner(dep_id, dep_tree, processed, sorted);
498    }
499
500    sorted.push(pkg_id);
501}
502
503fn atomic_write(path: &Path, data: &str) -> std::io::Result<()> {
504    let temp_path = path
505        .parent()
506        .unwrap_or_else(|| Path::new("."))
507        .join("Cargo.toml.work");
508    std::fs::write(&temp_path, data)?;
509    std::fs::rename(&temp_path, path)?;
510
511    Ok(())
512}
513
514#[cfg(test)]
515mod test {
516    use super::*;
517
518    #[allow(unused_imports)] // Not being detected
519    use assert_fs::prelude::*;
520    use predicates::prelude::*;
521
522    mod set_package_version {
523        use super::*;
524
525        #[test]
526        fn succeeds() {
527            let temp = assert_fs::TempDir::new().unwrap();
528            temp.copy_from("tests/fixtures/simple", &["**"]).unwrap();
529            let manifest_path = temp.child("Cargo.toml");
530
531            let meta = cargo_metadata::MetadataCommand::new()
532                .manifest_path(manifest_path.path())
533                .exec()
534                .unwrap();
535            assert_eq!(meta.packages[0].version.to_string(), "0.1.0");
536
537            set_package_version(manifest_path.path(), "2.0.0", false).unwrap();
538
539            let meta = cargo_metadata::MetadataCommand::new()
540                .manifest_path(manifest_path.path())
541                .exec()
542                .unwrap();
543            assert_eq!(meta.packages[0].version.to_string(), "2.0.0");
544
545            temp.close().unwrap();
546        }
547    }
548
549    mod update_lock {
550        use super::*;
551
552        #[test]
553        fn in_pkg() {
554            let temp = assert_fs::TempDir::new().unwrap();
555            temp.copy_from("tests/fixtures/simple", &["**"]).unwrap();
556            let manifest_path = temp.child("Cargo.toml");
557            let lock_path = temp.child("Cargo.lock");
558
559            set_package_version(manifest_path.path(), "2.0.0", false).unwrap();
560            lock_path.assert(predicate::path::eq_file(Path::new(
561                "tests/fixtures/simple/Cargo.lock",
562            )));
563
564            update_lock(manifest_path.path()).unwrap();
565            lock_path.assert(
566                predicate::path::eq_file(Path::new("tests/fixtures/simple/Cargo.lock")).not(),
567            );
568
569            temp.close().unwrap();
570        }
571
572        #[test]
573        fn in_pure_workspace() {
574            let temp = assert_fs::TempDir::new().unwrap();
575            temp.copy_from("tests/fixtures/pure_ws", &["**"]).unwrap();
576            let manifest_path = temp.child("b/Cargo.toml");
577            let lock_path = temp.child("Cargo.lock");
578
579            set_package_version(manifest_path.path(), "2.0.0", false).unwrap();
580            lock_path.assert(predicate::path::eq_file(Path::new(
581                "tests/fixtures/pure_ws/Cargo.lock",
582            )));
583
584            update_lock(manifest_path.path()).unwrap();
585            lock_path.assert(
586                predicate::path::eq_file(Path::new("tests/fixtures/pure_ws/Cargo.lock")).not(),
587            );
588
589            temp.close().unwrap();
590        }
591
592        #[test]
593        fn in_mixed_workspace() {
594            let temp = assert_fs::TempDir::new().unwrap();
595            temp.copy_from("tests/fixtures/mixed_ws", &["**"]).unwrap();
596            let manifest_path = temp.child("Cargo.toml");
597            let lock_path = temp.child("Cargo.lock");
598
599            set_package_version(manifest_path.path(), "2.0.0", false).unwrap();
600            lock_path.assert(predicate::path::eq_file(Path::new(
601                "tests/fixtures/mixed_ws/Cargo.lock",
602            )));
603
604            update_lock(manifest_path.path()).unwrap();
605            lock_path.assert(
606                predicate::path::eq_file(Path::new("tests/fixtures/mixed_ws/Cargo.lock")).not(),
607            );
608
609            temp.close().unwrap();
610        }
611    }
612
613    mod sort_workspace {
614        use super::*;
615
616        #[test]
617        fn circular_dev_dependency() {
618            let temp = assert_fs::TempDir::new().unwrap();
619            temp.copy_from("tests/fixtures/mixed_ws", &["**"]).unwrap();
620            let manifest_path = temp.child("a/Cargo.toml");
621            manifest_path
622                .write_str(
623                    r#"
624    [package]
625    name = "a"
626    version = "0.1.0"
627    authors = []
628
629    [dev-dependencies]
630    b = { path = "../" }
631    "#,
632                )
633                .unwrap();
634            let root_manifest_path = temp.child("Cargo.toml");
635            let meta = cargo_metadata::MetadataCommand::new()
636                .manifest_path(root_manifest_path.path())
637                .exec()
638                .unwrap();
639
640            let sorted = sort_workspace(&meta);
641            let root_package = meta.resolve.as_ref().unwrap().root.as_ref().unwrap();
642            assert_ne!(
643                sorted[0], root_package,
644                "The root package must not be the first one to be published."
645            );
646
647            temp.close().unwrap();
648        }
649    }
650}