pyrls 0.1.0

A single-binary release automation tool for Python projects
Documentation
use std::{collections::BTreeMap, fs, path::Path};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub release: ReleaseConfig,
    #[serde(default)]
    pub versioning: VersioningConfig,
    #[serde(default)]
    pub monorepo: MonorepoConfig,
    #[serde(default)]
    pub version_files: Vec<VersionFileConfig>,
    #[serde(default)]
    pub changelog: ChangelogConfig,
    #[serde(default)]
    pub publish: PublishConfig,
    #[serde(default)]
    pub github: GitHubConfig,
}

impl Config {
    pub fn load(path: &Path) -> Result<Self> {
        let raw = fs::read_to_string(path)
            .with_context(|| format!("failed to read config {}", path.display()))?;
        let config: Self =
            toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
        Ok(config)
    }

    pub fn validate(&self) -> Result<()> {
        if self.release.branch.trim().is_empty() {
            bail!("release.branch must not be empty");
        }

        if self.release.tag_prefix.trim().is_empty() {
            bail!("release.tag_prefix must not be empty");
        }

        if !self.monorepo.enabled && self.version_files.is_empty() {
            bail!("at least one [[version_files]] entry is required");
        }

        if !matches!(
            self.monorepo.release_mode.as_str(),
            "unified" | "per_package"
        ) {
            bail!("monorepo.release_mode must be one of: unified, per_package");
        }

        for package in &self.monorepo.packages {
            if package.trim().is_empty() {
                bail!("monorepo.packages entries must not be empty");
            }
        }

        for version_file in &self.version_files {
            validate_version_file(version_file)?;
        }

        let provider = self.publish.provider.trim();
        if provider.is_empty() {
            bail!("publish.provider must not be empty");
        }

        if !matches!(provider, "uv" | "twine") {
            bail!("publish.provider must be one of: uv, twine");
        }

        if self.publish.repository.trim().is_empty() {
            bail!("publish.repository must not be empty");
        }

        if self.publish.dist_dir.trim().is_empty() {
            bail!("publish.dist_dir must not be empty");
        }

        if let Some(url) = &self.publish.repository_url
            && url.trim().is_empty()
        {
            bail!("publish.repository_url must not be empty when provided");
        }

        for (field, value) in [
            ("publish.username_env", self.publish.username_env.as_deref()),
            ("publish.password_env", self.publish.password_env.as_deref()),
            ("publish.token_env", self.publish.token_env.as_deref()),
        ] {
            if matches!(value, Some(raw) if raw.trim().is_empty()) {
                bail!("{field} must not be empty when provided");
            }
        }

        Ok(())
    }

    pub fn section_for_commit_type(&self, commit_type: &str) -> Option<String> {
        match self.changelog.sections.get(commit_type) {
            Some(toml::Value::Boolean(false)) => None,
            Some(toml::Value::String(value)) => Some(value.clone()),
            Some(_) => None,
            None => Some(default_section_for_commit_type(commit_type).to_string()),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonorepoConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub packages: Vec<String>,
    #[serde(default = "default_monorepo_release_mode")]
    pub release_mode: String,
}

impl MonorepoConfig {
    pub fn is_multi_package(&self) -> bool {
        self.enabled
    }
}

impl Default for MonorepoConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            packages: Vec::new(),
            release_mode: default_monorepo_release_mode(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseConfig {
    #[serde(default = "default_branch")]
    pub branch: String,
    #[serde(default = "default_tag_prefix")]
    pub tag_prefix: String,
    #[serde(default = "default_changelog_file")]
    pub changelog_file: String,
    #[serde(default = "default_pr_title")]
    pub pr_title: String,
}

impl Default for ReleaseConfig {
    fn default() -> Self {
        Self {
            branch: default_branch(),
            tag_prefix: default_tag_prefix(),
            changelog_file: default_changelog_file(),
            pr_title: default_pr_title(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersioningConfig {
    #[serde(default = "default_strategy")]
    pub strategy: String,
    #[serde(default = "default_initial_version")]
    pub initial_version: String,
}

impl Default for VersioningConfig {
    fn default() -> Self {
        Self {
            strategy: default_strategy(),
            initial_version: default_initial_version(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionFileConfig {
    pub path: String,
    pub key: Option<String>,
    pub pattern: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChangelogConfig {
    #[serde(default)]
    pub sections: BTreeMap<String, toml::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_publish_provider")]
    pub provider: String,
    #[serde(default = "default_publish_repository")]
    pub repository: String,
    #[serde(default)]
    pub repository_url: Option<String>,
    #[serde(default = "default_publish_dist_dir")]
    pub dist_dir: String,
    #[serde(default)]
    pub trusted_publishing: bool,
    #[serde(default)]
    pub oidc: bool,
    #[serde(default)]
    pub username_env: Option<String>,
    #[serde(default)]
    pub password_env: Option<String>,
    #[serde(default)]
    pub token_env: Option<String>,
}

impl Default for PublishConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            provider: default_publish_provider(),
            repository: default_publish_repository(),
            repository_url: None,
            dist_dir: default_publish_dist_dir(),
            trusted_publishing: false,
            oidc: false,
            username_env: None,
            password_env: None,
            token_env: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubConfig {
    pub owner: Option<String>,
    pub repo: Option<String>,
    #[serde(default = "default_github_api_base")]
    pub api_base: String,
    #[serde(default = "default_github_token_env")]
    pub token_env: String,
    #[serde(default = "default_release_branch_prefix")]
    pub release_branch_prefix: String,
    #[serde(default = "default_pending_label")]
    pub pending_label: String,
    #[serde(default = "default_tagged_label")]
    pub tagged_label: String,
}

impl Default for GitHubConfig {
    fn default() -> Self {
        Self {
            owner: None,
            repo: None,
            api_base: default_github_api_base(),
            token_env: default_github_token_env(),
            release_branch_prefix: default_release_branch_prefix(),
            pending_label: default_pending_label(),
            tagged_label: default_tagged_label(),
        }
    }
}

fn validate_version_file(version_file: &VersionFileConfig) -> Result<()> {
    if version_file.path.trim().is_empty() {
        bail!("version file path must not be empty");
    }

    if version_file.key.is_none() && version_file.pattern.is_none() {
        bail!(
            "version file {} must define either `key` or `pattern`",
            version_file.path
        );
    }

    Ok(())
}

fn default_branch() -> String {
    "main".to_string()
}

fn default_tag_prefix() -> String {
    "v".to_string()
}

fn default_changelog_file() -> String {
    "CHANGELOG.md".to_string()
}

fn default_pr_title() -> String {
    "chore(release): {version}".to_string()
}

fn default_strategy() -> String {
    "conventional_commits".to_string()
}

fn default_initial_version() -> String {
    "0.1.0".to_string()
}

fn default_publish_provider() -> String {
    "uv".to_string()
}

fn default_publish_repository() -> String {
    "pypi".to_string()
}

fn default_publish_dist_dir() -> String {
    "dist".to_string()
}

fn default_monorepo_release_mode() -> String {
    "unified".to_string()
}

fn default_github_api_base() -> String {
    "https://api.github.com".to_string()
}

fn default_github_token_env() -> String {
    "GITHUB_TOKEN".to_string()
}

fn default_release_branch_prefix() -> String {
    "pyrls/release".to_string()
}

fn default_pending_label() -> String {
    "autorelease: pending".to_string()
}

fn default_tagged_label() -> String {
    "autorelease: tagged".to_string()
}

fn default_section_for_commit_type(commit_type: &str) -> &'static str {
    match commit_type {
        "feat" => "Added",
        "fix" => "Fixed",
        "refactor" | "perf" => "Changed",
        _ => "Changed",
    }
}

#[cfg(test)]
mod tests {
    use super::Config;

    #[test]
    fn config_requires_version_files() {
        let config = Config {
            version_files: Vec::new(),
            ..toml::from_str("").expect("default config")
        };

        let error = config.validate().expect_err("validation should fail");
        assert!(error.to_string().contains("version_files"));
    }

    #[test]
    fn config_rejects_unknown_publish_provider() {
        let config = Config {
            version_files: vec![super::VersionFileConfig {
                path: "pyproject.toml".to_string(),
                key: Some("project.version".to_string()),
                pattern: None,
            }],
            publish: super::PublishConfig {
                provider: "poetry".to_string(),
                ..Default::default()
            },
            ..toml::from_str("").expect("default config")
        };

        let error = config.validate().expect_err("validation should fail");
        assert!(error.to_string().contains("publish.provider"));
    }

    #[test]
    fn monorepo_config_allows_empty_top_level_version_files() {
        let config = Config {
            monorepo: super::MonorepoConfig {
                enabled: true,
                packages: vec!["packages/core".to_string()],
                release_mode: "per_package".to_string(),
            },
            version_files: Vec::new(),
            ..toml::from_str("").expect("default config")
        };

        config.validate().expect("validation should pass");
    }
}