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