use axoproject::WorkspaceInfo;
use axoproject::{errors::AxoprojectError, platforms::triple_to_display_name};
use camino::Utf8PathBuf;
use cargo_dist_schema::PrRunMode;
use semver::Version;
use serde::Deserialize;
use crate::{
config::{
self, CiStyle, CompressionImpl, Config, DistMetadata, InstallerStyle, PublishStyle,
ZipStyle,
},
do_generate,
errors::{DistError, DistResult, Result},
GenerateArgs, SortedMap, METADATA_DIST, PROFILE_DIST,
};
#[derive(Debug)]
pub struct InitArgs {
pub yes: bool,
pub no_generate: bool,
pub with_json_config: Option<Utf8PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct MultiDistMetadata {
workspace: Option<DistMetadata>,
#[serde(default)]
packages: SortedMap<String, DistMetadata>,
}
pub fn do_init(cfg: &Config, args: &InitArgs) -> Result<()> {
let workspace = config::get_project()?;
let mut workspace_toml = config::load_cargo_toml(&workspace.manifest_path)?;
let check = console::style("✔".to_string()).for_stderr().green();
let did_add_profile = init_dist_profile(cfg, &mut workspace_toml)?;
eprintln!("let's setup your cargo-dist config...");
eprintln!();
let multi_meta = if let Some(json_path) = &args.with_json_config {
let src = axoasset::SourceFile::load_local(json_path)?;
let multi_meta: MultiDistMetadata = src.deserialize_json()?;
multi_meta
} else {
let meta = get_new_dist_metadata(cfg, args, &workspace)?;
MultiDistMetadata {
workspace: Some(meta),
packages: SortedMap::new(),
}
};
if let Some(meta) = &multi_meta.workspace {
let metadata = config::get_toml_metadata(&mut workspace_toml, true);
apply_dist_to_metadata(metadata, meta);
}
eprintln!();
config::save_cargo_toml(&workspace.manifest_path, workspace_toml)?;
if did_add_profile {
eprintln!("{check} added [profile.dist] to your root Cargo.toml");
}
eprintln!("{check} added [workspace.metadata.dist] to your root Cargo.toml");
for (_idx, package) in workspace.packages() {
let meta = multi_meta.packages.get(&package.name);
let needs_edit = meta.is_some();
if needs_edit {
let mut package_toml = config::load_cargo_toml(&package.manifest_path)?;
let metadata = config::get_toml_metadata(&mut package_toml, false);
let mut writing_metadata = false;
if let Some(meta) = meta {
apply_dist_to_metadata(metadata, meta);
writing_metadata = true;
}
config::save_cargo_toml(&package.manifest_path, package_toml)?;
if writing_metadata {
eprintln!(
"{check} added [package.metadata.dist] to {}'s Cargo.toml",
package.name
);
}
}
}
eprintln!("{check} cargo-dist is setup!");
eprintln!();
if !args.no_generate {
eprintln!("running 'cargo dist generate' to apply any changes");
eprintln!();
let ci_args = GenerateArgs {
check: false,
modes: vec![],
};
do_generate(cfg, &ci_args)?;
}
Ok(())
}
fn init_dist_profile(_cfg: &Config, workspace_toml: &mut toml_edit::Document) -> Result<bool> {
let profiles = workspace_toml["profile"].or_insert(toml_edit::table());
if let Some(t) = profiles.as_table_mut() {
t.set_implicit(true)
}
let dist_profile = &mut profiles[PROFILE_DIST];
if !dist_profile.is_none() {
return Ok(false);
}
let mut new_profile = toml_edit::table();
{
let new_profile = new_profile.as_table_mut().unwrap();
new_profile.insert("inherits", toml_edit::value("release"));
new_profile.insert("lto", toml_edit::value("thin"));
new_profile
.decor_mut()
.set_prefix("\n# The profile that 'cargo dist' will build with\n")
}
dist_profile.or_insert(new_profile);
Ok(true)
}
fn get_new_dist_metadata(
cfg: &Config,
args: &InitArgs,
workspace_info: &WorkspaceInfo,
) -> DistResult<DistMetadata> {
use dialoguer::{Confirm, Input, MultiSelect, Select};
let has_config = workspace_info
.cargo_metadata_table
.as_ref()
.and_then(|t| t.as_object())
.map(|t| t.contains_key(METADATA_DIST))
.unwrap_or(false);
let mut meta = if has_config {
config::parse_metadata_table(
&workspace_info.manifest_path,
workspace_info.cargo_metadata_table.as_ref(),
)?
} else {
DistMetadata {
cargo_dist_version: Some(std::env!("CARGO_PKG_VERSION").parse().unwrap()),
rust_toolchain_version: None,
ci: None,
installers: None,
tap: None,
system_dependencies: None,
targets: None,
dist: None,
include: None,
auto_includes: None,
windows_archive: None,
unix_archive: None,
npm_scope: None,
checksum: None,
precise_builds: None,
merge_tasks: None,
fail_fast: None,
install_path: None,
features: None,
default_features: None,
all_features: None,
publish_jobs: None,
publish_prereleases: None,
create_release: None,
pr_run_mode: None,
allow_dirty: None,
ssldotcom_windows_sign: None,
msvc_crt_static: None,
}
};
let orig_meta = meta.clone();
let theme = dialoguer::theme::ColorfulTheme {
checked_item_prefix: console::style(" [x]".to_string()).for_stderr().green(),
unchecked_item_prefix: console::style(" [ ]".to_string()).for_stderr().dim(),
active_item_style: console::Style::new().for_stderr().cyan().bold(),
..dialoguer::theme::ColorfulTheme::default()
};
let check = console::style("✔".to_string()).for_stderr().green();
let notice = console::style("⚠️".to_string()).for_stderr().yellow();
let current_version: Version = std::env!("CARGO_PKG_VERSION").parse().unwrap();
if let Some(desired_version) = &meta.cargo_dist_version {
if desired_version != ¤t_version && !desired_version.pre.starts_with("github-") {
let default = true;
let prompt = format!(
r#"update your project to this version of cargo-dist?
{} => {}"#,
desired_version, current_version
);
let response = if args.yes {
default
} else {
let res = Confirm::with_theme(&theme)
.with_prompt(prompt)
.default(default)
.interact()?;
eprintln!();
res
};
if response {
meta.cargo_dist_version = Some(current_version);
} else {
Err(DistError::NoUpdateVersion {
project_version: desired_version.clone(),
running_version: current_version,
})?;
}
}
} else {
let prompt = format!(
r#"looks like you deleted the cargo-dist-version key, add it back?
this is the version of cargo-dist your releases should use
(you're currently running {})"#,
current_version
);
let default = true;
let response = if args.yes {
default
} else {
let res = Confirm::with_theme(&theme)
.with_prompt(prompt)
.default(default)
.interact()?;
eprintln!();
res
};
if response {
meta.cargo_dist_version = Some(current_version);
} else {
}
}
{
let default_platforms = crate::default_desktop_targets();
let mut known = crate::known_desktop_targets();
let config_vals = meta.targets.as_deref().unwrap_or(&default_platforms);
let cli_vals = cfg.targets.as_slice();
for val in config_vals.iter().chain(cli_vals) {
if !known.contains(val) {
known.push(val.clone());
}
}
let desc = move |triple: &str| -> String {
let pretty = triple_to_display_name(triple).unwrap_or("[unknown]");
format!("{pretty} ({triple})")
};
known.sort_by_cached_key(|k| desc(k).to_uppercase());
let mut defaults = vec![];
let mut keys = vec![];
for item in &known {
let config_had_it = config_vals.contains(item);
let cli_had_it = cli_vals.contains(item);
let default = config_had_it || cli_had_it;
defaults.push(default);
keys.push(desc(item));
}
let prompt = r#"what platforms do you want to build for?
(select with arrow keys and space, submit with enter)"#;
let selected = if args.yes {
defaults
.iter()
.enumerate()
.filter_map(|(idx, enabled)| enabled.then_some(idx))
.collect()
} else {
let res = MultiSelect::with_theme(&theme)
.items(&keys)
.defaults(&defaults)
.with_prompt(prompt)
.interact()?;
eprintln!();
res
};
meta.targets = Some(selected.into_iter().map(|i| known[i].clone()).collect());
}
if meta.ci.as_deref().unwrap_or_default().is_empty() {
let known = &[CiStyle::Github];
let mut defaults = vec![];
let mut keys = vec![];
let mut github_key = 0;
for item in known {
let mut default = meta
.ci
.as_ref()
.map(|ci| ci.contains(item))
.unwrap_or(false)
|| cfg.ci.contains(item);
#[allow(irrefutable_let_patterns)]
if let CiStyle::Github = item {
github_key = 0;
if let Some(repo_url) = &workspace_info.repository_url {
if repo_url.contains("github.com") {
default = true;
}
}
}
defaults.push(default);
keys.push(match item {
CiStyle::Github => "github",
});
}
let prompt = r#"enable Github CI and Releases?"#;
let default = defaults[github_key];
let github_selected = if args.yes {
default
} else {
let res = Confirm::with_theme(&theme)
.with_prompt(prompt)
.default(default)
.interact()?;
eprintln!();
res
};
let selected = if github_selected {
vec![github_key]
} else {
vec![]
};
let ci: Vec<_> = selected.into_iter().map(|i| known[i]).collect();
meta.ci = if ci.is_empty() { None } else { Some(ci) };
}
let has_github_ci = meta
.ci
.as_ref()
.map(|ci| ci.contains(&CiStyle::Github))
.unwrap_or(false);
if has_github_ci && workspace_info.repository_url.is_none() {
let conflict = workspace_info.warnings.iter().find_map(|w| {
if let AxoprojectError::InconsistentRepositoryKey {
file1,
url1,
file2,
url2,
} = w
{
Some(AxoprojectError::InconsistentRepositoryKey {
file1: file1.clone(),
url1: url1.clone(),
file2: file2.clone(),
url2: url2.clone(),
})
} else {
None
}
});
if let Some(inner) = conflict {
Err(DistError::CantEnableGithubUrlInconsistent { inner })?;
} else {
Err(DistError::CantEnableGithubNoUrl)?;
}
}
if has_github_ci && meta.pr_run_mode.is_none() {
let default_val = PrRunMode::default();
let cur_val = meta.pr_run_mode.unwrap_or(default_val);
let desc = |val| match val {
PrRunMode::Skip => "skip - don't check the release process in PRs",
PrRunMode::Plan => "plan - run 'cargo dist plan' on PRs (recommended)",
PrRunMode::Upload => "upload - build and upload an artifacts.zip to the PR (expensive)",
};
let items = [PrRunMode::Skip, PrRunMode::Plan, PrRunMode::Upload];
let default = items
.iter()
.position(|val| val == &cur_val)
.expect("someone added a pr_run_mode but forgot to add it to 'init'");
let prompt = r#"check your release process in pull requests?"#;
let selection = Select::with_theme(&theme)
.with_prompt(prompt)
.items(&items.iter().map(|mode| desc(*mode)).collect::<Vec<_>>())
.default(default)
.interact()?;
eprintln!();
let result = items[selection];
meta.pr_run_mode = Some(result);
}
let has_ci = meta.ci.as_ref().map(|ci| !ci.is_empty()).unwrap_or(false);
{
let known: &[InstallerStyle] = if has_ci {
&[
InstallerStyle::Shell,
InstallerStyle::Powershell,
InstallerStyle::Npm,
InstallerStyle::Homebrew,
InstallerStyle::Msi,
]
} else {
eprintln!("{notice} no CI backends enabled, most installers have been hidden");
&[InstallerStyle::Msi]
};
let mut defaults = vec![];
let mut keys = vec![];
for item in known {
let config_had_it = meta
.installers
.as_deref()
.unwrap_or_default()
.contains(item);
let cli_had_it = cfg.installers.contains(item);
let default = config_had_it || cli_had_it;
defaults.push(default);
keys.push(match item {
InstallerStyle::Shell => "shell",
InstallerStyle::Powershell => "powershell",
InstallerStyle::Npm => "npm",
InstallerStyle::Homebrew => "homebrew",
InstallerStyle::Msi => "msi",
});
}
let prompt = r#"what installers do you want to build?
(select with arrow keys and space, submit with enter)"#;
let selected = if args.yes {
defaults
.iter()
.enumerate()
.filter_map(|(idx, enabled)| enabled.then_some(idx))
.collect()
} else {
let res = MultiSelect::with_theme(&theme)
.items(&keys)
.defaults(&defaults)
.with_prompt(prompt)
.interact()?;
eprintln!();
res
};
meta.installers = Some(selected.into_iter().map(|i| known[i]).collect());
}
let mut publish_jobs = orig_meta.publish_jobs.clone().unwrap_or(vec![]);
if meta
.installers
.as_deref()
.unwrap_or_default()
.contains(&InstallerStyle::Homebrew)
{
let homebrew_is_new = !orig_meta
.installers
.as_deref()
.unwrap_or_default()
.contains(&InstallerStyle::Homebrew);
if homebrew_is_new {
let prompt = r#"you've enabled Homebrew support; if you want cargo-dist
to automatically push package updates to a tap (repository) for you,
please enter the tap name (in GitHub owner/name format)"#;
let default = "".to_string();
let tap: String = if args.yes {
default
} else {
let res = Input::with_theme(&theme)
.with_prompt(prompt)
.allow_empty(true)
.interact_text()?;
eprintln!();
res
};
let tap = tap.trim();
if tap.is_empty() {
eprintln!("Homebrew packages will not be automatically published");
meta.tap = None;
} else {
meta.tap = Some(tap.to_owned());
publish_jobs.push(PublishStyle::Homebrew);
eprintln!("{check} Homebrew package will be published to {tap}");
eprintln!(
r#"{check} You must provision a GitHub token and expose it as a secret named
HOMEBREW_TAP_TOKEN in GitHub Actions. For more information,
see the documentation:
https://opensource.axo.dev/cargo-dist/book/installers/homebrew.html"#
);
}
}
}
meta.publish_jobs = if publish_jobs.is_empty() {
None
} else {
Some(publish_jobs)
};
if meta
.installers
.as_deref()
.unwrap_or_default()
.contains(&InstallerStyle::Npm)
{
let npm_is_new = !orig_meta
.installers
.as_deref()
.unwrap_or_default()
.contains(&InstallerStyle::Npm);
if npm_is_new {
let prompt = r#"you've enabled npm support, please enter the @scope you want to use
this is the "namespace" the package will be published under
(leave blank to publish globally)"#;
let default = "".to_string();
let scope: String = if args.yes {
default
} else {
let res = Input::with_theme(&theme)
.with_prompt(prompt)
.allow_empty(true)
.validate_with(|v: &String| {
let v = v.trim();
if v.is_empty() {
Ok(())
} else if let Some(v) = v.strip_prefix('@') {
if v.is_empty() {
Err("@ must be followed by something")
} else {
Ok(())
}
} else {
Err("npm scopes must start with @")
}
})
.interact_text()?;
eprintln!();
res
};
let scope = scope.trim();
if scope.is_empty() {
eprintln!("{check} npm packages will be published globally");
meta.npm_scope = None;
} else {
meta.npm_scope = Some(scope.to_owned());
eprintln!("{check} npm packages will be published under {scope}");
}
eprintln!();
}
const TAR_GZ: Option<ZipStyle> = Some(ZipStyle::Tar(CompressionImpl::Gzip));
if meta.unix_archive != TAR_GZ || meta.windows_archive != TAR_GZ {
let prompt = r#"the npm installer requires binaries to be distributed as .tar.gz, is that ok?
otherwise we would distribute your binaries as .zip on windows, .tar.xz everywhere else
(this is a hopefully temporary limitation of the npm installer's implementation)"#;
let default = true;
let force_targz = if args.yes {
default
} else {
let res = Confirm::with_theme(&theme)
.with_prompt(prompt)
.default(default)
.interact()?;
eprintln!();
res
};
if force_targz {
meta.unix_archive = TAR_GZ;
meta.windows_archive = TAR_GZ;
} else {
Err(DistError::MustEnableTarGz)?;
}
}
}
Ok(meta)
}
fn apply_dist_to_metadata(metadata: &mut toml_edit::Item, meta: &DistMetadata) {
let dist_metadata = &mut metadata[METADATA_DIST];
if !dist_metadata.is_table() {
*dist_metadata = toml_edit::table();
}
let table = dist_metadata.as_table_mut().unwrap();
let DistMetadata {
cargo_dist_version,
rust_toolchain_version,
dist,
ci,
installers,
tap,
system_dependencies: _,
targets,
include,
auto_includes,
windows_archive,
unix_archive,
npm_scope,
checksum,
precise_builds,
merge_tasks,
fail_fast,
install_path,
features,
all_features,
default_features,
publish_jobs,
publish_prereleases,
create_release,
pr_run_mode,
allow_dirty,
ssldotcom_windows_sign,
msvc_crt_static,
} = &meta;
apply_optional_value(
table,
"cargo-dist-version",
"# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax)\n",
cargo_dist_version.as_ref().map(|v| v.to_string()),
);
apply_optional_value(
table,
"rust-toolchain-version",
"# The preferred Rust toolchain to use in CI (rustup toolchain syntax)\n",
rust_toolchain_version.as_deref(),
);
apply_string_list(table, "ci", "# CI backends to support\n", ci.as_ref());
apply_string_list(
table,
"installers",
"# The installers to generate for each app\n",
installers.as_ref(),
);
apply_optional_value(
table,
"tap",
"# A GitHub repo to push Homebrew formulas to\n",
tap.clone(),
);
apply_string_list(
table,
"targets",
"# Target platforms to build apps for (Rust target-triple syntax)\n",
targets.as_ref(),
);
apply_optional_value(
table,
"dist",
"# Whether to consider the binaries in a package for distribution (defaults true)\n",
*dist,
);
apply_string_list(
table,
"include",
"# Extra static files to include in each App (path relative to this Cargo.toml's dir)\n",
include.as_ref(),
);
apply_optional_value(
table,
"auto-includes",
"# Whether to auto-include files like READMEs, LICENSEs, and CHANGELOGs (default true)\n",
*auto_includes,
);
apply_optional_value(
table,
"windows-archive",
"# The archive format to use for windows builds (defaults .zip)\n",
windows_archive.map(|a| a.ext()),
);
apply_optional_value(
table,
"unix-archive",
"# The archive format to use for non-windows builds (defaults .tar.xz)\n",
unix_archive.map(|a| a.ext()),
);
apply_optional_value(
table,
"npm-scope",
"# A namespace to use when publishing this package to the npm registry\n",
npm_scope.as_deref(),
);
apply_optional_value(
table,
"checksum",
"# Checksums to generate for each App\n",
checksum.map(|c| c.ext()),
);
apply_optional_value(
table,
"precise-builds",
"# Build only the required packages, and individually\n",
*precise_builds,
);
apply_optional_value(
table,
"merge-tasks",
"# Whether to run otherwise-parallelizable tasks on the same machine\n",
*merge_tasks,
);
apply_optional_value(
table,
"fail-fast",
"# Whether failing tasks should make us give up on all other tasks\n",
*fail_fast,
);
apply_optional_value(
table,
"create-release",
"# Whether cargo-dist should create a Github Release or use an existing draft\n",
*create_release,
);
apply_optional_value(
table,
"install-path",
"# Path that installers should place binaries in\n",
install_path.as_ref().map(|p| p.to_string()),
);
apply_string_list(
table,
"features",
"# Features to pass to cargo build\n",
features.as_ref(),
);
apply_optional_value(
table,
"default-features",
"# Whether default-features should be enabled with cargo build\n",
*default_features,
);
apply_optional_value(
table,
"all-features",
"# Whether to pass --all-features to cargo build\n",
*all_features,
);
apply_string_list(
table,
"publish-jobs",
"# Publish jobs to run in CI\n",
publish_jobs.as_ref(),
);
apply_optional_value(
table,
"publish-prereleases",
"# Whether to publish prereleases to package managers\n",
*publish_prereleases,
);
apply_optional_value(
table,
"pr-run-mode",
"# Publish jobs to run in CI\n",
pr_run_mode.as_ref().map(|m| m.to_string()),
);
apply_string_list(
table,
"allow-dirty",
"# Skip checking whether the specified configuration files are up to date\n",
allow_dirty.as_ref(),
);
apply_optional_value(
table,
"msvc-crt-static",
"# Whether +crt-static should be used on msvc\n",
*msvc_crt_static,
);
apply_optional_value(
table,
"ssldotcom-windows-sign",
"",
ssldotcom_windows_sign.as_ref().map(|p| p.to_string()),
);
table
.decor_mut()
.set_prefix("\n# Config for 'cargo dist'\n");
}
fn apply_optional_value<I>(table: &mut toml_edit::Table, key: &str, desc: &str, val: Option<I>)
where
I: Into<toml_edit::Value>,
{
if let Some(val) = val {
table.insert(key, toml_edit::value(val));
table.key_decor_mut(key).unwrap().set_prefix(desc);
} else {
table.remove(key);
}
}
fn apply_string_list<I>(table: &mut toml_edit::Table, key: &str, desc: &str, list: Option<I>)
where
I: IntoIterator,
I::Item: std::fmt::Display,
{
if let Some(list) = list {
let items = list.into_iter().map(|i| i.to_string()).collect::<Vec<_>>();
table.insert(key, toml_edit::Item::Value(items.into_iter().collect()));
table.key_decor_mut(key).unwrap().set_prefix(desc);
} else {
table.remove(key);
}
}