use std::path::PathBuf;
use std::str::FromStr;
use clap::{Args, Parser, Subcommand, ValueEnum};
use regex::Regex;
use semver::BuildMetadata;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct PanReleaseArgs {
#[arg(short, long)]
pub path: Option<PathBuf>,
#[clap(subcommand)]
pub subcommand: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Init(InitArgs),
Release(RelArgs),
Show(ShowArgs),
}
#[derive(Args, Debug)]
pub struct InitArgs {
#[arg(long)]
pub interactive: bool,
}
#[derive(Args, Debug)]
pub struct RelArgs {
#[arg(value_name = "LEVEL|VERSION", help_heading = "Version")]
pub level_or_version: TargetVersion,
}
#[derive(Args, Debug)]
pub struct ShowArgs {
#[arg(long, value_enum, default_value_t = ShowFormat::Semver)]
pub format: ShowFormat,
}
impl ShowArgs {
pub fn render_version(&self, version: &semver::Version) -> String {
self.format.render_version(version)
}
}
#[derive(ValueEnum, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[value(rename_all = "kebab-case")]
pub enum ShowFormat {
#[default]
Semver,
DockerTag,
}
impl ShowFormat {
pub fn render_version(&self, version: &semver::Version) -> String {
match self {
ShowFormat::Semver => version.to_string(),
ShowFormat::DockerTag => version.to_string().replace('+', "_"),
}
}
}
#[derive(Clone, Debug)]
pub enum TargetVersion {
Relative(BumpLevel),
Absolute(semver::Version),
}
impl TargetVersion {
pub fn apply_with_slug(
&self,
current: semver::Version,
slug: Option<&str>,
) -> anyhow::Result<semver::Version> {
match self {
TargetVersion::Relative(bump_level) => {
bump_level.apply_with_slug(current, slug)
}
TargetVersion::Absolute(version) => {
Ok(version.to_owned())
}
}
}
}
impl clap::builder::ValueParserFactory for TargetVersion {
type Parser = TargetVersionParser;
fn value_parser() -> Self::Parser {
TargetVersionParser
}
}
impl std::str::FromStr for TargetVersion {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(bump_level) = BumpLevel::from_str(s, false) {
Ok(TargetVersion::Relative(bump_level))
} else {
Ok(TargetVersion::Absolute(
semver::Version::parse(s).map_err(|e| e.to_string())?,
))
}
}
}
#[derive(Copy, Clone)]
pub struct TargetVersionParser;
impl clap::builder::TypedValueParser for TargetVersionParser {
type Value = TargetVersion;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let inner_parser = TargetVersion::from_str;
inner_parser.parse_ref(cmd, arg, value)
}
fn possible_values(
&self,
) -> Option<Box<dyn Iterator<Item=clap::builder::PossibleValue> + '_>> {
let inner_parser = clap::builder::EnumValueParser::<BumpLevel>::new();
#[allow(clippy::needless_collect)] inner_parser.possible_values().map(|ps| {
let ps = ps.collect::<Vec<_>>();
let ps: Box<dyn Iterator<Item=clap::builder::PossibleValue> + '_> =
Box::new(ps.into_iter());
ps
})
}
}
#[derive(ValueEnum, Debug, Clone, Copy)]
#[value(rename_all = "kebab-case")]
pub enum BumpLevel {
Major,
Minor,
Patch,
Post,
}
impl BumpLevel {
#[cfg(test)]
fn apply(
&self,
current: semver::Version,
) -> anyhow::Result<semver::Version> {
self.apply_with_slug(current, None)
}
pub(crate) fn apply_with_slug(
&self,
current: semver::Version,
slug: Option<&str>,
) -> anyhow::Result<semver::Version> {
match self {
BumpLevel::Major => {
Ok(semver::Version {
major: current.major + 1,
minor: 0,
patch: 0,
pre: Default::default(),
build: Default::default(),
})
}
BumpLevel::Minor => {
Ok(semver::Version {
major: current.major,
minor: current.minor + 1,
patch: 0,
pre: Default::default(),
build: Default::default(),
})
}
BumpLevel::Patch => {
Ok(semver::Version {
major: current.major,
minor: current.minor,
patch: current.patch + 1,
pre: Default::default(),
build: Default::default(),
})
}
BumpLevel::Post => {
let build = if let Some(slug) = slug {
let existing = parse_build(current.build.as_str());
match existing {
Some((name, Some(ver))) if name == slug => {
BuildMetadata::new(&format!("{slug}.r{}", ver + 1))
}
_ => {
BuildMetadata::new(&format!("{slug}.r1"))
}
}
.map_err(|e| anyhow::anyhow!(
"Invalid slug '{slug}': semver build metadata requires alphanumeric \
identifiers separated by dots (no slashes or special characters): {e}"
))?
} else {
parse_build(current.build.as_str()).map(|(name, ver)| {
BuildMetadata::new(&format!("{}.r{}", name, ver.map(|v| v + 1).unwrap_or(1)))
})
.unwrap_or_else(|| BuildMetadata::new("dev.r1"))
.map_err(|e| anyhow::anyhow!(
"Failed to construct post-release build metadata: {e}"
))?
};
Ok(semver::Version {
major: current.major,
minor: current.minor,
patch: current.patch,
pre: Default::default(),
build,
})
}
}
}
}
pub fn parse_build(build_info: &str) -> Option<(&str, Option<u64>)> {
let re = Regex::new(r"(?P<name>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)(\.r(?P<ver>\d+))$").unwrap();
re.captures(build_info)
.and_then(|captures| {
let ver = captures.name("ver")
.and_then(|m| m.as_str().parse::<u64>().ok());
captures.name("name").map(|name| (name.as_str(), ver))
})
.or(Some((build_info, None)))
.filter(|(name, ver)| !name.is_empty() || ver.is_some())
}
#[cfg(test)]
mod test {
use clap::Parser;
use crate::args::{BumpLevel, Commands, PanReleaseArgs, ShowArgs, ShowFormat, parse_build};
#[test]
fn parse_simple_post_release() {
assert_eq!(Some(("build", Some(12))), parse_build("build.r12"))
}
#[test]
fn parse_no_ver_post_release() {
assert_eq!(Some(("build", None)), parse_build("build"))
}
#[test]
fn parse_complex_post_release() {
assert_eq!(Some(("feature-dev.rc", Some(12))), parse_build("feature-dev.rc.r012"))
}
#[test]
fn parse_invalid_post_release() {
assert_eq!(Some(("build.rc12", None)), parse_build("build.rc12"))
}
#[test]
fn increment_patch() {
assert_eq!(
String::from("1.2.4"),
BumpLevel::Patch.apply(semver::Version::parse("1.2.3").unwrap()).unwrap().to_string()
)
}
#[test]
fn increment_minor() {
assert_eq!(
String::from("1.3.0"),
BumpLevel::Minor.apply(semver::Version::parse("1.2.3").unwrap()).unwrap().to_string()
)
}
#[test]
fn increment_major() {
assert_eq!(
String::from("2.0.0"),
BumpLevel::Major.apply(semver::Version::parse("1.2.3").unwrap()).unwrap().to_string()
)
}
#[test]
fn increment_postrel() {
assert_eq!(
String::from("1.2.3+feat.r2"),
BumpLevel::Post.apply(semver::Version::parse("1.2.3+feat.r1").unwrap()).unwrap().to_string()
)
}
#[test]
fn apply_with_slug_first_post() {
assert_eq!(
String::from("1.2.3+my-feature.r1"),
BumpLevel::Post.apply_with_slug(
semver::Version::parse("1.2.3").unwrap(),
Some("my-feature"),
).unwrap().to_string()
)
}
#[test]
fn apply_with_slug_increment() {
assert_eq!(
String::from("1.2.3+my-feature.r2"),
BumpLevel::Post.apply_with_slug(
semver::Version::parse("1.2.3+my-feature.r1").unwrap(),
Some("my-feature"),
).unwrap().to_string()
)
}
#[test]
fn apply_with_slug_different_slug_resets() {
assert_eq!(
String::from("1.2.3+new-slug.r1"),
BumpLevel::Post.apply_with_slug(
semver::Version::parse("1.2.3+old-slug.r5").unwrap(),
Some("new-slug"),
).unwrap().to_string()
)
}
#[test]
fn apply_with_slug_none_preserves_original() {
assert_eq!(
String::from("1.2.3+feat.r2"),
BumpLevel::Post.apply_with_slug(
semver::Version::parse("1.2.3+feat.r1").unwrap(),
None,
).unwrap().to_string()
)
}
#[test]
fn apply_with_slug_none_defaults_to_dev() {
assert_eq!(
String::from("1.2.3+dev.r1"),
BumpLevel::Post.apply_with_slug(
semver::Version::parse("1.2.3").unwrap(),
None,
).unwrap().to_string()
)
}
#[test]
fn apply_with_invalid_slug_returns_error() {
let result = BumpLevel::Post.apply_with_slug(
semver::Version::parse("1.0.0").unwrap(),
Some("feature/my-branch"),
);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("feature/my-branch"), "error should mention the invalid slug: {msg}");
}
#[test]
fn show_semver_format_preserves_version() {
let args = ShowArgs { format: ShowFormat::Semver };
let version = semver::Version::parse("1.2.3+feat.r1").unwrap();
assert_eq!("1.2.3+feat.r1", args.render_version(&version));
}
#[test]
fn show_docker_tag_format_replaces_build_separator() {
let args = ShowArgs { format: ShowFormat::DockerTag };
let version = semver::Version::parse("1.2.3+feat.r1").unwrap();
assert_eq!("1.2.3_feat.r1", args.render_version(&version));
}
#[test]
fn parse_show_docker_tag_format() {
let parsed = PanReleaseArgs::try_parse_from(["panrelease", "show", "--format", "docker-tag"]).unwrap();
match parsed.subcommand {
Commands::Show(show_args) => assert_eq!(ShowFormat::DockerTag, show_args.format),
_ => panic!("expected show subcommand"),
}
}
}