panrelease 0.19.0

Utility to release software
Documentation
use std::path::PathBuf;
use std::str::FromStr;

use clap::{Args, Parser, Subcommand, ValueEnum};
use regex::Regex;
use semver::BuildMetadata;

/// Simple program release and tag software versions
#[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 {
    /// Initialize a .panproject.toml configuration file
    Init(InitArgs),
    /// Release a new version
    Release(RelArgs),
    /// Show current version
    Show(ShowArgs),
}

#[derive(Args, Debug)]
pub struct InitArgs {
    /// Interactively prompt for optional configuration (strict mode, changelog, etc.)
    #[arg(long)]
    pub interactive: bool,
}

#[derive(Args, Debug)]
pub struct RelArgs {
    /// Either bump by LEVEL or set the VERSION for all selected packages
    #[arg(value_name = "LEVEL|VERSION", help_heading = "Version")]
    pub level_or_version: TargetVersion,
}

#[derive(Args, Debug)]
pub struct ShowArgs {
    /// Output format for the current version
    #[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)] // Erasing a lifetime
        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 {
                    // Slug-aware mode: use slug as the build name
                    let existing = parse_build(current.build.as_str());
                    match existing {
                        Some((name, Some(ver))) if name == slug => {
                            // Same slug, increment counter
                            BuildMetadata::new(&format!("{slug}.r{}", ver + 1))
                        }
                        _ => {
                            // Different slug or no existing metadata: start at r1
                            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 {
                    // Original behavior
                    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() {
        // Slash is illegal in semver build metadata
        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"),
        }
    }
}