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