rlls 0.0.36

Cut a version, tag it, and publish a GitHub Release with raw git notes
Documentation
use anyhow::Result;
use chrono::Utc;
use fs_err as fs;
use std::collections::HashSet;
use std::path::PathBuf;

pub fn maybe_update(
    cfg: &crate::config::Config,
    tag: &str,
    notes: &str,
    disabled: bool,
) -> Result<()> {
    if disabled {
        return Ok(());
    }

    let path = cfg
        .changelog_path
        .clone()
        .unwrap_or_else(|| "CHANGELOG.md".into());

    let mut out = String::new();
    let date = Utc::now().format("%Y-%m-%d").to_string();
    out.push_str(&format!("## {} {}\n\n", tag, date));

    let mut usernames = HashSet::new();
    for line in notes.lines() {
        if line.trim_start().starts_with("- ") {
            if let Some(name_part) =
                line.trim_start_matches("- ").split('<').nth(1)
            {
                let username =
                    name_part.split('@').next().unwrap_or("").trim();
                if !username.is_empty() {
                    usernames.insert(username.to_string());
                }
            }
        }
    }
    let plural = usernames.len() > 1;
    let header = if plural { "Authors" } else { "Author" };

    let mut formatted_notes = String::new();
    let mut lines = notes.lines().peekable();

    while let Some(line) = lines.next() {
        if line.trim().starts_with("### Authors")
            || line.trim().starts_with("### Author")
        {
            formatted_notes.push_str(&format!("### {}\n", header));
            for l in lines.by_ref() {
                if l.trim().is_empty() {
                    formatted_notes.push('\n');
                    break;
                }

                // example: "- John Doe <jdoe@domain.com> (3)"
                let mut username = "unknown";
                if let Some(email_part) = l.split('<').nth(1) {
                    username = email_part
                        .split('@')
                        .next()
                        .unwrap_or("unknown")
                        .trim();
                }

                if let Some((_, count_part)) = l.rsplit_once('(') {
                    let count = count_part
                        .trim_end_matches(')')
                        .split_whitespace()
                        .next()
                        .unwrap_or("0")
                        .parse::<u32>()
                        .unwrap_or(0);

                    let word =
                        if count == 1 { "commit" } else { "commits" };
                    formatted_notes.push_str(&format!(
                        "- {} ({} {})\n",
                        username, count, word
                    ));
                } else {
                    formatted_notes
                        .push_str(&format!("- {}\n", username));
                }
            }
        } else {
            formatted_notes.push_str(line);
            formatted_notes.push('\n');
        }
    }

    out.push_str(&formatted_notes);
    out.push('\n');

    let p = PathBuf::from(&path);
    let existing = if p.exists() {
        fs::read_to_string(&p).unwrap_or_default()
    } else {
        String::new()
    };

    let mut final_doc = String::new();
    if let Some(h) = &cfg.changelog_header {
        if !existing.starts_with(h) {
            final_doc.push_str(h);
            final_doc.push('\n');
            final_doc.push('\n');
        }
    }

    final_doc.push_str(&out);
    final_doc.push_str(&existing);

    fs::write(&p, final_doc)?;
    eprintln!("[ok] updated changelog at {}", p.display());
    Ok(())
}