use std::fs::File;
use std::process::{Command, Stdio};
use std::{fmt::Display, process::exit};
use cargo_metadata::semver::Prerelease;
use clap::{Args, Subcommand, ValueEnum};
use cargo_metadata::{semver::Version, MetadataCommand};
use printable_shell_command::PrintableShellCommand;
use schemars::{schema_for, JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use crate::common::commit_wrapped_operation::CommitWrappedOperation;
use crate::common::config::Config;
use crate::common::debug::DebugPrintable;
use crate::common::inference::get_stdout;
use crate::common::vcs::{vcs_or_infer, VcsKind};
use crate::common::{
ecosystem::{Ecosystem, EcosystemArgs},
package_manager::PACKAGE_JSON_PATH,
};
#[derive(Args, Debug)]
pub(crate) struct VersionArgs {
#[command(subcommand)]
command: VersionCommand,
#[command(flatten)]
ecosystem_args: EcosystemArgs,
}
#[derive(Debug, Subcommand, Clone)]
enum VersionCommand {
Get(VersionGetArgs),
Describe(VersionDescribeArgs),
Set(VersionSetArgs),
Bump(VersionBumpArgs),
}
impl Serialize for VersionCommand {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(match self {
VersionCommand::Get(_version_get_args) => "get",
VersionCommand::Describe(_version_describe_args) => "describe",
VersionCommand::Set(_version_set_args) => "set",
VersionCommand::Bump(_version_bump_args) => "bump",
})
}
}
#[derive(Debug, Serialize, JsonSchema, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum VersionCommandType {
Get,
Describe,
Set,
Bump,
}
impl From<&VersionCommand> for VersionCommandType {
fn from(value: &VersionCommand) -> Self {
match value {
VersionCommand::Get(_) => Self::Get,
VersionCommand::Describe(_) => Self::Describe,
VersionCommand::Set(_) => Self::Set,
VersionCommand::Bump(_) => Self::Bump,
}
}
}
#[derive(Args, Debug, Clone)]
struct VersionGetArgs {
#[clap(long)]
pub no_prefix: bool,
}
#[derive(Args, Debug, Clone)]
struct VersionDescribeArgs {
#[clap(long)]
pub use_vcs: Option<VcsKind>,
}
#[derive(Args, Debug, Clone)]
struct VersionSetArgs {
#[clap()]
pub version: String,
#[command(flatten)]
commit_args: CommitOperationArgs,
}
#[derive(Args, Debug, Clone)]
struct VersionBumpArgs {
#[command(subcommand)]
pub magnitude_subcommand: VersionBumpMagnitude,
#[command(flatten)]
commit_args: CommitOperationArgs,
}
#[derive(Debug, ValueEnum, Clone, Copy)]
enum NumberedVersionComponent {
Major,
Minor,
Patch,
}
#[derive(Debug, Subcommand, Clone)]
enum VersionBumpMagnitude {
Major,
Minor,
Patch,
Dev(VersionBumpDevArgs),
}
impl VersionBumpMagnitude {
fn bump_component(&self) -> Option<NumberedVersionComponent> {
match self {
VersionBumpMagnitude::Dev(version_bump_dev_args) => {
version_bump_dev_args.bump_component
}
_ => None,
}
}
}
#[derive(Debug, Serialize, Clone, Copy, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum VersionBumpMagnitudeType {
Major,
Minor,
Patch,
Dev,
}
impl From<&VersionBumpMagnitude> for VersionBumpMagnitudeType {
fn from(value: &VersionBumpMagnitude) -> Self {
match value {
VersionBumpMagnitude::Major => VersionBumpMagnitudeType::Major,
VersionBumpMagnitude::Minor => VersionBumpMagnitudeType::Minor,
VersionBumpMagnitude::Patch => VersionBumpMagnitudeType::Patch,
VersionBumpMagnitude::Dev(_) => VersionBumpMagnitudeType::Dev,
}
}
}
#[derive(Args, Debug, Clone)]
struct VersionBumpDevArgs {
#[clap(long)]
bump_component: Option<NumberedVersionComponent>,
}
impl Display for VersionBumpMagnitude {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
VersionBumpMagnitude::Major => "major",
VersionBumpMagnitude::Minor => "minor",
VersionBumpMagnitude::Patch => "patch",
VersionBumpMagnitude::Dev(_) => "dev",
}
)
}
}
impl Serialize for VersionBumpMagnitude {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
#[derive(Args, Debug, Clone)]
pub(crate) struct CommitOperationArgs {
#[clap(long, group = "commit-group")]
commit: bool,
#[clap(long, group = "commit-group")]
pub commit_using: Option<VcsKind>,
}
impl CommitOperationArgs {
pub fn perform_commit(&self) -> bool {
self.commit || self.commit_using.is_some()
}
}
#[derive(Deserialize)]
struct PackageJSONWithVersion {
version: Option<String>,
}
pub(crate) fn npm_get_version() -> Result<String, String> {
let Ok(file) = File::open(PACKAGE_JSON_PATH) else {
return Err("Could not file `package.json`".to_owned());
};
let Ok(package_json) = serde_json::from_reader::<_, PackageJSONWithVersion>(file) else {
return Err("Could not read `package.json`".to_owned());
};
match package_json.version {
Some(version) => Ok(match version.strip_prefix("v") {
Some(version) => version.to_owned(),
None => version,
}),
None => Err("No version field found in `package.json`".to_owned()),
}
}
pub(crate) fn cargo_get_version() -> Result<String, String> {
let mut command = MetadataCommand::new();
let Ok(metadata) = command
.manifest_path("./Cargo.toml")
.current_dir(".")
.exec()
else {
return Err("Could not file `Cargo.toml`".to_owned());
};
if let Some(root_package) = metadata.root_package() {
return Ok(root_package.version.to_string());
};
let workspace_default_packages = metadata.workspace_default_packages();
if let Some(workspace_default_package) = workspace_default_packages.first() {
eprintln!(
"Getting the Rust package version from the {} workspace default package: {}",
if workspace_default_packages.len() == 1 {
"sole"
} else {
"first"
},
workspace_default_package.name
);
return Ok(workspace_default_package.version.to_string());
}
let workspace_packages = metadata.workspace_packages();
if let Some(workspace_package) = workspace_packages.first() {
eprintln!(
"Getting the Rust package version from the {} workspace package: {}",
if workspace_packages.len() == 1 {
"sole"
} else {
"first"
},
workspace_package.name
);
return Ok(workspace_package.version.to_string());
}
Err("Could not get version.".to_owned())
}
fn print_version(version: &str, version_get_args: &VersionGetArgs) {
let prefix = if version_get_args.no_prefix { "" } else { "v" };
print!("{}{}", prefix, version);
}
fn dev_bump(version: Version, bump_component: Option<NumberedVersionComponent>) -> Version {
let mut version = version.clone();
let bump_component = bump_component.unwrap_or(NumberedVersionComponent::Patch);
match bump_component {
NumberedVersionComponent::Major => {
version.major += 1;
version.minor = 0;
version.patch = 0;
}
NumberedVersionComponent::Minor => {
version.minor += 1;
version.patch += 0;
}
NumberedVersionComponent::Patch => {
version.patch += 1;
}
}
version.pre = Prerelease::new("dev").unwrap();
version
}
fn remove_prerelease(version: Version) -> Version {
let mut version = version.clone();
version.pre = Prerelease::EMPTY;
version
}
fn npm_bump_version(version_bump_magnitude: &VersionBumpMagnitude) {
if matches!(version_bump_magnitude, VersionBumpMagnitude::Dev(_)) {
let version = npm_get_version().expect("Could not get current version.");
let version = Version::parse(&version).expect("Could not parse current version.");
npm_set_version(dev_bump(version, version_bump_magnitude.bump_component()));
return;
}
PrintableShellCommand::new("npm")
.arg_each([
"version",
"--no-git-tag-version",
&version_bump_magnitude.to_string(),
])
.debug_print()
.status()
.expect("Could not bump version using `npm`");
}
fn cargo_bump_version(version_bump_magnitude: &VersionBumpMagnitude) {
if matches!(version_bump_magnitude, VersionBumpMagnitude::Dev(_)) {
let version = cargo_get_version().expect("Could not get current version.");
let version = Version::parse(&version).expect("Could not parse current version.");
cargo_set_version(dev_bump(version, version_bump_magnitude.bump_component()));
return;
}
if matches!(version_bump_magnitude, VersionBumpMagnitude::Patch) {
let version = cargo_get_version().expect("Could not get current version.");
let version = Version::parse(&version).expect("Could not parse current version.");
if !version.pre.is_empty() {
cargo_set_version(remove_prerelease(version));
return;
}
}
eprintln!("Assuming `cargo-bump` is installed…");
PrintableShellCommand::new("cargo")
.args(["bump", &version_bump_magnitude.to_string()])
.debug_print()
.status()
.expect("Could not bump version using `cargo-bump`");
}
pub(crate) fn detect_ecosystem_by_getting_version(
ecosystem_args: &EcosystemArgs,
) -> Option<(Ecosystem, String)> {
for (ecosystem, get_version) in [
(
Ecosystem::JavaScript,
npm_get_version as fn() -> Result<String, String>,
),
(
Ecosystem::Rust,
cargo_get_version as fn() -> Result<String, String>,
),
] {
if let Some(required_ecosystem) = ecosystem_args.ecosystem {
if required_ecosystem != ecosystem {
continue;
}
}
if let Ok(version) = get_version() {
return Some((ecosystem, version));
}
}
None
}
pub(crate) fn must_detect_ecosystem_by_getting_version(
ecosystem_args: &EcosystemArgs,
) -> (Ecosystem, String) {
detect_ecosystem_by_getting_version(ecosystem_args)
.expect("Could not detect an ecosystem for this repo.")
}
fn version_get_and_print(ecosystem_args: &EcosystemArgs, version_get_args: &VersionGetArgs) {
let Some((_, version)) = detect_ecosystem_by_getting_version(ecosystem_args) else {
eprintln!("No version found.");
exit(1);
};
print_version(&version, version_get_args);
}
fn version_describe_and_print(version_describe_args: &VersionDescribeArgs) {
let Ok(vcs) = vcs_or_infer(version_describe_args.use_vcs) else {
eprintln!("Could not determine VCS to use.");
exit(1);
};
let description = match vcs {
VcsKind::Git => {
let mut git_command = PrintableShellCommand::new("git");
git_command.args(["describe", "--tags"]);
let Some(description) = get_stdout(git_command) else {
eprintln!("Could not get description using `git`.");
exit(1);
};
description
}
VcsKind::Jj => {
let mut jj_command = PrintableShellCommand::new("jj");
jj_command.args(["log", "--no-graph", "--reversed"]);
jj_command.args(["--revisions", "latest(tags() & ::@-)"]);
jj_command.args([
"--template",
"commit_id.short(8) ++ \" \" ++ tags ++ \"\n\"",
]);
let Some(commits) = get_stdout(jj_command) else {
eprintln!("Could not get description using `jj`.");
exit(1);
};
let lines: Vec<&str> = commits.split("\n").collect();
if lines.is_empty() {
eprintln!("Could not get enough commits to describe using `jj`.");
exit(1);
}
let first_line_parts: Vec<&str> = lines[0].split(" ").collect();
if first_line_parts.len() < 2 {
eprintln!("Could not get tag to describe using `jj`.");
exit(1);
}
let tag = first_line_parts[1];
if lines.len() == 1 {
tag.to_owned()
} else {
let latest: &str = lines.last().unwrap().split(" ").next().unwrap(); format!("{}-{}-g{}", tag, lines.len() - 1, latest)
}
}
VcsKind::Mercurial => {
eprintln!("Mercurial is unsupported for this operation.");
exit(1);
}
};
print!("{}", description);
}
fn version_bump(
ecosystem_args: &EcosystemArgs,
version_bump_magnitude: &VersionBumpMagnitude,
) -> Result<String, String> {
let auto_print_version = |repo_ecosystem: Ecosystem| {
eprintln!("Bumped version using ecosystem: {}", repo_ecosystem);
};
match must_detect_ecosystem_by_getting_version(ecosystem_args) {
(Ecosystem::JavaScript, _) => {
npm_bump_version(version_bump_magnitude);
auto_print_version(Ecosystem::JavaScript);
npm_get_version()
}
(Ecosystem::Rust, _) => {
cargo_bump_version(version_bump_magnitude);
auto_print_version(Ecosystem::Rust);
cargo_get_version()
}
}
}
fn npm_set_version(version: Version) {
PrintableShellCommand::new("npm")
.args(["version", "--no-git-tag-version", &version.to_string()])
.debug_print()
.status()
.expect("Could not bump version using `npm`");
}
fn cargo_set_version(version: Version) {
eprintln!("Assuming `cargo-bump` or `cargo-workspaces` is installed…");
if matches!(
PrintableShellCommand::new("cargo")
.arg_each(["bump", &version.to_string()])
.debug_print()
.status()
.map(|status| status.success()),
Ok(true)
) {
return;
};
if matches!(
PrintableShellCommand::new("cargo")
.arg_each([
"workspaces",
"version",
"--no-git-commit",
"--yes",
"--force",
"*",
"custom",
&version.to_string(),
])
.debug_print()
.status()
.map(|status| status.success()),
Ok(true)
) {
return;
};
panic!("Could not use `cargo-bump` or `cargo-workspaces` to set the version.");
}
fn version_set(ecosystem_args: &EcosystemArgs, version: Version) {
eprintln!("Setting version to: v{}", version);
match must_detect_ecosystem_by_getting_version(ecosystem_args) {
(Ecosystem::JavaScript, _) => {
npm_set_version(version);
}
(Ecosystem::Rust, _) => {
cargo_set_version(version);
}
}
}
#[derive(Serialize, Debug, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub(crate) struct PostVersionInfo {
command: VersionCommandType,
#[serde(skip_serializing_if = "Option::is_none")]
magnitude: Option<VersionBumpMagnitudeType>,
#[schemars(schema_with = "json_schema_serialize_version")]
version: Version,
}
fn json_schema_serialize_version(_: &mut SchemaGenerator) -> Schema {
schema_for!(String)
}
const POST_VERSION: &str = "postVersion";
fn post_version_change(info: PostVersionInfo) {
let config = Config::get();
let Some(post_version_command) = config.scripts.get(POST_VERSION) else {
return;
};
let Some((command, args)) = post_version_command.split_first() else {
panic!("Command is an empty list: {}", POST_VERSION);
};
let mut subprocess = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.spawn()
.unwrap();
serde_json::to_writer(subprocess.stdin.as_mut().unwrap(), &info).unwrap();
assert!(subprocess.wait().unwrap().success());
}
pub(crate) fn version_command(version_args: VersionArgs) {
let command = (&version_args.command).into();
match &version_args.command {
VersionCommand::Get(version_get_args) => {
version_get_and_print(&version_args.ecosystem_args, version_get_args);
}
VersionCommand::Describe(version_describe_args) => {
version_describe_and_print(version_describe_args);
}
VersionCommand::Set(version_set_args) => {
let commit_wrapped_operation =
CommitWrappedOperation::try_from(&version_set_args.commit_args).unwrap();
commit_wrapped_operation
.perform_operation(&|| {
let version = version_set_args
.version
.strip_prefix("v")
.unwrap_or(&version_set_args.version);
let version = Version::parse(version).expect("Invalid version specified");
version_set(&version_args.ecosystem_args, version.clone());
post_version_change(PostVersionInfo {
command,
magnitude: None,
version: version.clone(),
});
Ok(format!("Set version to: `v{}`", version))
})
.unwrap();
}
VersionCommand::Bump(version_bump_args) => {
Config::get();
let commit_wrapped_operation =
CommitWrappedOperation::try_from(&version_bump_args.commit_args).unwrap();
commit_wrapped_operation
.perform_operation(&|| {
let version_bump_magnitude: &VersionBumpMagnitude =
&version_bump_args.magnitude_subcommand;
let new_version =
version_bump(&version_args.ecosystem_args, version_bump_magnitude).unwrap();
let new_version =
Version::parse(&new_version).expect("Internal error: Invalid new version");
post_version_change(PostVersionInfo {
command,
magnitude: Some(version_bump_magnitude.into()),
version: new_version.clone(),
});
Ok(format!(
"Bump to next {} version: `v{}`",
version_bump_magnitude, new_version
))
})
.unwrap();
}
};
}