pyrls 0.1.0

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

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

pub fn read_pattern(path: &Path, pattern: &str) -> Result<Option<String>> {
    let contents =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    let (prefix, suffix) = pattern_parts(pattern)?;

    Ok(contents
        .lines()
        .find_map(|line| extract_version(line, prefix, suffix)))
}

pub fn rewrite_pattern(path: &Path, pattern: &str, version: &str) -> Result<()> {
    let contents =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    let (prefix, suffix) = pattern_parts(pattern)?;
    let mut replaced = false;
    let updated = contents
        .lines()
        .map(|line| {
            if replaced {
                return line.to_string();
            }

            if extract_version(line, prefix, suffix).is_some() {
                replaced = true;
                let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
                format!("{}{}{}{}", " ".repeat(indent), prefix, version, suffix)
            } else {
                line.to_string()
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    if !replaced {
        bail!("pattern not found in {}", path.display());
    }

    let mut final_contents = updated;
    if contents.ends_with('\n') {
        final_contents.push('\n');
    }
    fs::write(path, final_contents).with_context(|| format!("failed to write {}", path.display()))
}

fn pattern_parts(pattern: &str) -> Result<(&str, &str)> {
    pattern
        .split_once("{version}")
        .ok_or_else(|| anyhow::anyhow!("pattern must contain {{version}} placeholder"))
}

fn extract_version(line: &str, prefix: &str, suffix: &str) -> Option<String> {
    let trimmed = line.trim();
    let rest = trimmed.strip_prefix(prefix)?;
    let version = rest.strip_suffix(suffix)?;
    Some(version.to_string())
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::{read_pattern, rewrite_pattern};

    #[test]
    fn reads_and_rewrites_version_pattern() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("__init__.py");
        fs::write(&path, "__version__ = \"0.1.0\"\n").expect("write python file");

        let version =
            read_pattern(&path, "__version__ = \"{version}\"").expect("read version pattern");
        assert_eq!(version.as_deref(), Some("0.1.0"));

        rewrite_pattern(&path, "__version__ = \"{version}\"", "0.2.0")
            .expect("rewrite version pattern");
        let contents = fs::read_to_string(path).expect("read updated python file");
        assert_eq!(contents, "__version__ = \"0.2.0\"\n");
    }

    #[test]
    fn preserves_indentation_when_rewriting() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("__init__.py");
        fs::write(&path, "if True:\n    __version__ = '0.1.0'\n").expect("write python file");

        rewrite_pattern(&path, "__version__ = '{version}'", "0.2.0")
            .expect("rewrite version pattern");
        let contents = fs::read_to_string(path).expect("read updated python file");
        assert!(contents.contains("    __version__ = '0.2.0'"));
    }
}