Skip to main content

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