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_key(path: &Path, key: &str) -> Result<Option<String>> {
    let contents =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    let (section, name) = split_key(key)?;
    let mut current_section = String::new();

    for line in contents.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') && trimmed.ends_with(']') {
            current_section = trimmed[1..trimmed.len() - 1].trim().to_string();
            continue;
        }

        if current_section == section
            && let Some((candidate, value)) = trimmed.split_once('=')
            && candidate.trim() == name
        {
            return Ok(Some(value.trim().to_string()));
        }
    }

    Ok(None)
}

pub fn rewrite_key(path: &Path, key: &str, version: &str) -> Result<()> {
    let contents =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    let (section, name) = split_key(key)?;
    let mut current_section = String::new();
    let mut replaced = false;
    let updated = contents
        .lines()
        .map(|line| {
            let trimmed = line.trim();
            if trimmed.starts_with('[') && trimmed.ends_with(']') {
                current_section = trimmed[1..trimmed.len() - 1].trim().to_string();
                return line.to_string();
            }

            if !replaced && current_section == section
                && let Some((candidate, _)) = trimmed.split_once('=')
                && candidate.trim() == name
            {
                replaced = true;
                let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
                return format!("{}{} = {}", " ".repeat(indent), name, version);
            }

            line.to_string()
        })
        .collect::<Vec<_>>()
        .join("\n");

    if !replaced {
        bail!("key {key} 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 split_key(key: &str) -> Result<(String, String)> {
    let Some((section, name)) = key.split_once('.') else {
        bail!("cfg key must be section.name");
    };
    Ok((section.to_string(), name.to_string()))
}

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

    use tempfile::tempdir;

    use super::{read_key, rewrite_key};

    #[test]
    fn reads_and_rewrites_cfg_key() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("setup.cfg");
        fs::write(&path, "[metadata]\nname = demo\nversion = 0.1.0\n").expect("write cfg file");

        let version = read_key(&path, "metadata.version").expect("read cfg version");
        assert_eq!(version.as_deref(), Some("0.1.0"));

        rewrite_key(&path, "metadata.version", "0.2.0").expect("rewrite cfg version");
        let version = read_key(&path, "metadata.version").expect("read updated version");
        assert_eq!(version.as_deref(), Some("0.2.0"));
    }
}