releasaurus-core 0.16.0

A comprehensive release automation tool that streamlines the software release process across multiple programming languages and forge platforms
Documentation
//! Configuration and URL types for Git forge platform connections.
use secrecy::SecretString;
use std::env;

use crate::error::{ReleasaurusError, Result};

/// URL scheme for a remote repository.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scheme {
    Http,
    Https,
}

impl std::fmt::Display for Scheme {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Scheme::Http => write!(f, "http"),
            Scheme::Https => write!(f, "https"),
        }
    }
}

/// A repository URL with its parsed components.
///
/// Construct this directly from the components of your parsed URL.
/// The CLI crate uses `git_url_parse` to populate it; library
/// consumers can use any URL parser they prefer.
///
/// `Display` produces `scheme://host[:port]/path`.
///
/// ```rust
/// use releasaurus_core::forge::{
///     config::{RepoUrl, Scheme},
/// };
///
/// let url = RepoUrl {
///     scheme: Scheme::Https,
///     host: "github.com".into(),
///     owner: "my-org".into(),
///     name: "my-repo".into(),
///     path: "my-org/my-repo".into(),
///     port: None,
///     token: None,
/// };
/// assert_eq!(url.to_string(), "https://github.com/my-org/my-repo");
/// ```
#[derive(Debug, Clone)]
pub struct RepoUrl {
    /// Remote forge host (e.g. `"github.com"`).
    pub host: String,
    /// Repository owner or organisation.
    pub owner: String,
    /// Repository name.
    pub name: String,
    /// Full project path (e.g. `"owner/repo"` or
    /// `"group/subgroup/repo"` for nested GitLab groups).
    pub path: String,
    /// Optional port for self-hosted instances.
    pub port: Option<u16>,
    /// URL scheme.
    pub scheme: Scheme,
    /// Token embedded in the URL (e.g.
    /// `https://TOKEN@github.com/...`). Set to `None` if the URL
    /// contains no embedded token. [`SecretString`] comes from the
    /// [`secrecy`](https://docs.rs/secrecy) crate.
    pub token: Option<SecretString>,
}

impl RepoUrl {
    pub fn link_base_url(&self) -> String {
        format!("{}://{}", self.scheme, self.host)
    }
}

impl std::fmt::Display for RepoUrl {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.port {
            Some(port) => write!(
                f,
                "{}://{}:{}/{}",
                self.scheme, self.host, port, self.path
            ),
            None => write!(f, "{}://{}/{}", self.scheme, self.host, self.path),
        }
    }
}

/// Default number of commits to search when finding releases.
pub const DEFAULT_COMMIT_SEARCH_DEPTH: u64 = 400;
/// Default number of tag to search when looking for starting tags
pub const DEFAULT_TAG_SEARCH_DEPTH: u8 = 100;
/// Default page size for paginated commit queries
pub const DEFAULT_PAGE_SIZE: u8 = 50;
/// Default branch name prefix for release PRs.
pub const DEFAULT_PR_BRANCH_PREFIX: &str = "releasaurus-release";
/// Default color for releasaurus labels in hex format.
pub const DEFAULT_LABEL_COLOR: &str = "a47dab";
/// Label applied to release PRs after tagging is complete.
pub const TAGGED_LABEL: &str = "releasaurus:tagged";
/// Label applied to release PRs while waiting for merge.
pub const PENDING_LABEL: &str = "releasaurus:pending";

/// Represents the default token variable names that are checked for
/// authenticating each forge type
#[derive(Clone, Copy, strum::Display)]
pub enum TokenVar {
    #[strum(to_string = "GITHUB_TOKEN")]
    Github,
    #[strum(to_string = "GITLAB_TOKEN")]
    Gitlab,
    #[strum(to_string = "GITEA_TOKEN")]
    Gitea,
}

/// Resolve authentication token from multiple sources in priority order:
/// 1. Explicitly provided token (CLI argument)
/// 2. Token embedded in the URL
/// 3. Environment variable (forge-specific)
///
/// Returns an error if no token is found from any source.
pub fn resolve_token(
    cli_token: Option<SecretString>,
    url_token: Option<&SecretString>,
    token_var: TokenVar,
) -> Result<SecretString> {
    cli_token
        .or_else(|| url_token.cloned())
        .or_else(|| {
            env::var(token_var.to_string()).ok().map(SecretString::from)
        })
        .ok_or_else(|| {
            ReleasaurusError::AuthenticationError(
                "Token not provided".to_string(),
            )
        })
}

#[cfg(test)]
mod tests {
    use secrecy::ExposeSecret;

    use super::*;

    #[test]
    fn resolve_token_prefers_cli_token() {
        let cli_token = Some(SecretString::from("cli_token"));
        let url_token = SecretString::from("url_token");

        let result =
            resolve_token(cli_token, Some(&url_token), TokenVar::Github);

        assert_eq!(result.unwrap().expose_secret(), "cli_token");
    }

    #[test]
    fn resolve_token_falls_back_to_url_token() {
        let url_token = SecretString::from("url_token");

        let result = resolve_token(None, Some(&url_token), TokenVar::Gitlab);

        assert_eq!(result.unwrap().expose_secret(), "url_token");
    }

    #[test]
    fn resolve_token_falls_back_to_env_var() {
        temp_env::with_var(
            TokenVar::Github.to_string(),
            Some("env_token"),
            || {
                let result = resolve_token(None, None, TokenVar::Github);
                assert_eq!(result.unwrap().expose_secret(), "env_token");
            },
        );
    }

    #[test]
    fn resolve_token_errors_when_no_token_available() {
        temp_env::with_var_unset(TokenVar::Gitea.to_string(), || {
            let result = resolve_token(None, None, TokenVar::Gitea);
            assert!(result.is_err());
            let err = result.unwrap_err();
            assert!(matches!(err, ReleasaurusError::AuthenticationError(_)));
        });
    }

    #[test]
    fn resolve_token_cli_takes_precedence_over_env() {
        temp_env::with_var(
            TokenVar::Gitea.to_string(),
            Some("env_token"),
            || {
                let cli_token = Some(SecretString::from("cli_token"));
                let result = resolve_token(cli_token, None, TokenVar::Gitea);
                assert_eq!(result.unwrap().expose_secret(), "cli_token");
            },
        );
    }

    #[test]
    fn resolve_token_url_takes_precedence_over_env() {
        temp_env::with_var(
            TokenVar::Gitlab.to_string(),
            Some("env_token"),
            || {
                let url_token = SecretString::from("url_token");
                let result =
                    resolve_token(None, Some(&url_token), TokenVar::Gitlab);
                assert_eq!(result.unwrap().expose_secret(), "url_token");
            },
        );
    }
}