pyrls 0.1.0

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

use anyhow::{Context, Result, bail};
use toml::Value;

use crate::{
    cli::Cli,
    config::{ChangelogConfig, Config, GitHubConfig, VersionFileConfig},
    git::GitRepository,
    github, progress,
};

pub fn run(cli: &Cli) -> Result<()> {
    if cli.config.exists() {
        bail!("config already exists at {}", cli.config.display());
    }

    let repo = GitRepository::discover(".").ok();
    let repo_root = repo
        .as_ref()
        .map(|repo| repo.path())
        .unwrap_or(Path::new("."));

    let config = if cli.dry_run {
        build_config(repo.as_ref(), repo_root)
    } else {
        let sp = progress::spinner("Detecting project layout…");
        let result = build_config(repo.as_ref(), repo_root);
        sp.finish_and_clear();
        result
    };
    let rendered = toml::to_string_pretty(&config).context("failed to render config")?;

    if cli.dry_run {
        println!("Would create {}", cli.config.display());
        println!();
        print!("{rendered}");
        return Ok(());
    }

    fs::write(&cli.config, rendered)
        .with_context(|| format!("failed to write {}", cli.config.display()))?;

    println!("Created {}", cli.config.display());
    Ok(())
}

fn build_config(repo: Option<&GitRepository>, repo_root: &Path) -> Config {
    let branch = repo
        .and_then(|repo| repo.current_branch().ok())
        .filter(|branch| !branch.trim().is_empty())
        .unwrap_or_else(|| "main".to_string());
    let version_files = detect_version_files(repo_root);
    let initial_version =
        detect_initial_version(repo_root, &version_files).unwrap_or_else(|| "0.1.0".to_string());
    let mut github_config = GitHubConfig::default();

    if let Some(repo) = repo
        && let Ok(repo_ref) = github::detect_repo(repo, &github_config)
    {
        github_config.owner = Some(repo_ref.owner);
        github_config.repo = Some(repo_ref.name);
    }

    Config {
        release: crate::config::ReleaseConfig {
            branch,
            ..Default::default()
        },
        versioning: crate::config::VersioningConfig {
            initial_version,
            ..Default::default()
        },
        monorepo: Default::default(),
        version_files,
        changelog: default_changelog_config(),
        publish: Default::default(),
        github: github_config,
    }
}

fn default_changelog_config() -> ChangelogConfig {
    let sections = BTreeMap::from([
        ("docs".to_string(), Value::Boolean(false)),
        ("feat".to_string(), Value::String("Added".to_string())),
        ("fix".to_string(), Value::String("Fixed".to_string())),
        ("perf".to_string(), Value::String("Changed".to_string())),
        ("refactor".to_string(), Value::String("Changed".to_string())),
    ]);
    ChangelogConfig { sections }
}

fn detect_version_files(repo_root: &Path) -> Vec<VersionFileConfig> {
    let mut version_files = Vec::new();

    let pyproject_path = repo_root.join("pyproject.toml");
    if pyproject_path.exists() {
        version_files.push(VersionFileConfig {
            path: "pyproject.toml".to_string(),
            key: Some("project.version".to_string()),
            pattern: None,
        });
    }

    let setup_cfg_path = repo_root.join("setup.cfg");
    if setup_cfg_path.exists() {
        version_files.push(VersionFileConfig {
            path: "setup.cfg".to_string(),
            key: Some("metadata.version".to_string()),
            pattern: None,
        });
    }

    version_files.extend(detect_python_version_files(repo_root));

    if version_files.is_empty() {
        version_files.push(VersionFileConfig {
            path: "pyproject.toml".to_string(),
            key: Some("project.version".to_string()),
            pattern: None,
        });
    }

    version_files
}

fn detect_initial_version(repo_root: &Path, version_files: &[VersionFileConfig]) -> Option<String> {
    for version_file in version_files {
        let path = repo_root.join(&version_file.path);
        if !path.exists() {
            continue;
        }

        let value = if let Some(key) = &version_file.key {
            crate::version_files::read_key(&path, key).ok().flatten()
        } else if let Some(pattern) = &version_file.pattern {
            crate::version_files::read_pattern(&path, pattern)
                .ok()
                .flatten()
        } else {
            None
        };

        if value.is_some() {
            return value;
        }
    }

    None
}

fn detect_python_version_files(repo_root: &Path) -> Vec<VersionFileConfig> {
    let mut candidates = Vec::new();

    for relative in [PathBuf::from("src"), PathBuf::from(".")] {
        let dir = repo_root.join(&relative);
        if !dir.is_dir() {
            continue;
        }

        scan_python_dir(repo_root, &dir, &mut candidates);
    }

    candidates.sort_by(|left, right| left.path.cmp(&right.path));
    candidates.dedup_by(|left, right| left.path == right.path);
    candidates
}

fn scan_python_dir(repo_root: &Path, dir: &Path, candidates: &mut Vec<VersionFileConfig>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if matches!(
            path.file_name().and_then(|name| name.to_str()),
            Some(".git" | "target" | ".venv" | "venv" | "__pycache__")
        ) {
            continue;
        }

        if path.is_dir() {
            scan_python_dir(repo_root, &path, candidates);
            continue;
        }

        if path.file_name().and_then(|name| name.to_str()) != Some("__init__.py") {
            continue;
        }

        let Some(pattern) = detect_python_pattern(&path) else {
            continue;
        };
        let Ok(relative_path) = path.strip_prefix(repo_root) else {
            continue;
        };

        candidates.push(VersionFileConfig {
            path: relative_path.to_string_lossy().replace('\\', "/"),
            key: None,
            pattern: Some(pattern),
        });
    }
}

fn detect_python_pattern(path: &Path) -> Option<String> {
    let contents = fs::read_to_string(path).ok()?;

    for line in contents.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("__version__") {
            continue;
        }

        let (prefix, raw_value) = trimmed.split_once('=')?;
        let value = raw_value.trim();
        if value.len() < 2 {
            continue;
        }

        let quote = value.chars().next()?;
        if (quote != '"' && quote != '\'') || !value.ends_with(quote) {
            continue;
        }

        return Some(format!("{}= {}{{version}}{}", prefix, quote, quote));
    }

    None
}