semantic-release-cargo 2.3.25

A CLI to integrate Rust into a semantic-release workflow.
Documentation
// Copyright 2020 Steven Bosnick
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE-2.0 or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms

use std::{
    fs::File,
    io::{self, BufWriter, Write},
    path::{Path, PathBuf},
};

use anyhow::{Context, Error};
use clap::{builder::TypedValueParser, crate_version, Parser};
use log::Level;
use loggerv::{Logger, Output};

use semantic_release_cargo::{
    list_packages_with_arguments, prepare, publish, verify_conditions_with_alternate, PublishArgs,
};

/// Run semantic-release steps in the context of a cargo based Rust project.
#[derive(Parser)]
#[clap(version = crate_version!())]
struct Opt {
    /// Increases the logging level (use multiple times for more detail).
    #[clap(short, long, group = "logging", action = clap::ArgAction::Count)]
    verbose: u8,

    /// Explicitly set the log level.
    #[clap(
        short,
        long,
        group = "logging",
        value_parser = clap::builder::PossibleValuesParser::new(["error", "warn", "info", "debug", "trace"])
            .map(|s| s.parse::<log::Level>().unwrap()),
    )]
    log_level: Option<Level>,

    /// Specifies the output file to use instead of standard out.
    #[structopt(short, long)]
    output: Option<PathBuf>,

    #[clap(subcommand)]
    subcommand: Subcommand,
}

#[derive(Parser)]
enum Subcommand {
    /// List the packages that are included in the semantic release.
    ///
    /// The listed packages are all of the packages in the workspace and are listed
    /// in order based on their dependencies (it is a topological sort of the
    /// dependency graph). Packages that will not be published will have such an
    /// indication given after the name of the package.
    ///
    /// This is primarily a debugging aid and does not corresponds directly to
    /// a semantic release step.
    ListPackages(CommonOpt),

    /// Verify that the conditions for a release are satisfied
    ///
    /// The conditions for a release checked by this subcommand are:
    ///
    ///     1. That the CARGO_REGISTRY_TOKEN environment variable is set and is
    ///        non-empty.
    ///     2. That it can construct a reverse-dependencies-ordered list of the
    ///        packages in the root crate's workspace.
    ///     3. That it can parse the version for packages in the workspace in all of
    ///        the `Cargo.toml` files that form part of the workspace.
    ///
    /// This implements the `verifyConditions` step for `semantic-release` for a
    /// Cargo-based Rust workspace.
    #[clap(verbatim_doc_comment)]
    VerifyConditions(CommonOpt),

    /// Prepare the Rust workspace for a release.
    ///
    /// Preparing the workspace for a release updates the version of each crate in
    /// the workspace in the crate's `Cargo.toml` file, and adds or updates the
    /// version field of any workspace-relative path dependencies and
    /// build-dependencies.
    ///
    /// This implements the `prepare` step for `semantic-release` for a Cargo-based
    /// Rust workspace.
    Prepare(PrepareOpt),

    /// Publish the Rust workspace.
    ///
    /// Publishing the workspace publishes each crate in the workspace to
    /// crates.io except crates with the `package.publish` field set to `false` or
    /// set to any registries other than just crates.io. By default this will publish
    /// with the `allow-dirty` flag but this can be excluded with the `no-dirty`
    /// flag to this subcommand.
    ///
    /// This implements the `publish` step for `semantic-release` for a Cargo-based
    /// Rust workspace.
    Publish(PublishOpt),
}

#[derive(Parser)]
struct CommonOpt {
    /// The path to the `Cargo.toml` file for the root of the workspace.
    #[clap(long)]
    manifest_path: Option<PathBuf>,

    /// Specify an alternate-registry to publish the target crate to.
    #[clap(long)]
    registry: Option<String>,
}

#[derive(Parser)]
struct PrepareOpt {
    #[clap(flatten)]
    common: CommonOpt,

    /// The version to set in all crates in the workspace.
    next_version: String,
}

#[derive(Parser)]
struct PublishOpt {
    #[clap(flatten)]
    common: CommonOpt,

    /// Disallow publishing with uncommited files in the workspace.
    #[clap(long)]
    no_dirty: bool,

    /// The features to use when publishing the workspace.
    /// This is a comma separated list of key-value pairs where the key is the
    /// name of the package and the value a feature for that package.
    /// For example, `--features foo=bar,baz=qux` will set the `bar` feature for
    /// the `foo` package and the `qux` feature for the `baz` package.
    #[clap(long, value_parser = parse_key_val::<String, String>, value_delimiter = ',')]
    features: Vec<(String, String)>,
}

/// Parse a single key-value pair
fn parse_key_val<T, U>(
    s: &str,
) -> Result<(T, U), Box<dyn std::error::Error + Send + Sync + 'static>>
where
    T: std::str::FromStr,
    T::Err: std::error::Error + Send + Sync + 'static,
    U: std::str::FromStr,
    U::Err: std::error::Error + Send + Sync + 'static,
{
    let pos = s
        .find('=')
        .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{}`", s))?;
    Ok((s[..pos].parse()?, s[pos + 1..].parse()?))
}

impl Subcommand {
    fn run(&self, w: impl Write) -> Result<(), Error> {
        use Subcommand::*;

        match self {
            ListPackages(opt) => Ok(list_packages_with_arguments(
                w,
                opt.registry.as_deref(),
                opt.manifest_path(),
            )?),
            VerifyConditions(opt) => Ok(verify_conditions_with_alternate(
                w,
                opt.registry.as_deref(),
                opt.manifest_path(),
            )?),
            Prepare(opt) => Ok(prepare(
                w,
                opt.common.manifest_path(),
                opt.next_version.clone(),
            )?),
            Publish(opt) => Ok(publish(
                w,
                opt.common.manifest_path(),
                &PublishArgs {
                    no_dirty: Some(opt.no_dirty),
                    features: Some(opt.features.iter().cloned().fold(
                        Default::default(),
                        |mut a, (k, v)| {
                            a.entry(k).or_default().push(v);
                            a
                        },
                    )),
                    registry: opt.common.registry.clone(),
                },
            )?),
        }
    }
}

fn main() -> Result<(), Error> {
    let opt: Opt = Opt::parse();

    let logger = Logger::new()
        .output(&Level::Trace, Output::Stderr)
        .output(&Level::Debug, Output::Stderr);

    // Set the max level to initialize to based on the `log-level` flag if it's
    // available, otherwise fall back to verbosity.
    if let Some(log_level) = opt.log_level {
        logger.max_level(log_level).init()?;
    } else {
        logger.verbosity(opt.verbose.into()).init()?
    };

    match opt.output {
        Some(path) => {
            let file = File::create(&path)
                .with_context(|| format!("Failed to create output file {}", path.display()))?;
            opt.subcommand.run(BufWriter::new(file))
        }

        None => opt.subcommand.run(BufWriter::new(io::stdout())),
    }
}

impl CommonOpt {
    fn manifest_path(&self) -> Option<&Path> {
        self.manifest_path.as_deref()
    }
}