cargo-governor 2.0.0

Machine-First, LLM-Ready, CI/CD-Native release automation tool for Rust crates
Documentation
//! Changelog operations for bump service

use crate::error::Result;
use governor_core::domain::changelog::{
    Changelog, ChangelogEntry, ChangelogSection, ChangelogSectionConfig,
};
use governor_core::domain::version::SemanticVersion;
use std::fs;
use std::path::Path;

/// Default section configuration for changelog generation
fn default_sections() -> Vec<ChangelogSectionConfig> {
    vec![
        ChangelogSectionConfig {
            section: ChangelogSection::Breaking,
            title: "Breaking Changes".to_string(),
            order: 0,
            commit_types: vec!["feat!".to_string()],
        },
        ChangelogSectionConfig {
            section: ChangelogSection::Added,
            title: "Added".to_string(),
            order: 1,
            commit_types: vec!["feat".to_string()],
        },
        ChangelogSectionConfig {
            section: ChangelogSection::Fixed,
            title: "Fixed".to_string(),
            order: 2,
            commit_types: vec!["fix".to_string(), "perf".to_string()],
        },
        ChangelogSectionConfig {
            section: ChangelogSection::Changed,
            title: "Changed".to_string(),
            order: 3,
            commit_types: vec!["refactor".to_string(), "revert".to_string()],
        },
    ]
}

/// Commit types to exclude from changelog
fn excluded_types() -> Vec<&'static str> {
    vec!["docs", "test", "chore", "style", "ci", "build", "release"]
}

/// Generate changelog entry from commits since last tag
///
/// # Errors
///
/// Returns an error if git operations fail
#[allow(clippy::manual_let_else)]
#[allow(clippy::option_if_let_else)]
fn generate_changelog_from_commits(
    workspace_path: &str,
    version: &SemanticVersion,
) -> Result<Changelog> {
    use std::process::Command;

    // Get commits since last tag
    let tag_output = Command::new("git")
        .args(["tag", "-l", "--merged", "HEAD"])
        .current_dir(workspace_path)
        .output()
        .map_err(|e| crate::error::Error::Io(format!("Failed to get git tags: {e}")))?;

    let last_tag = String::from_utf8_lossy(&tag_output.stdout)
        .lines()
        .rfind(|line| line.starts_with('v'))
        .map(std::string::ToString::to_string);

    let mut git_args_vec = vec![
        "log".to_string(),
        "--pretty=format:%H|%s|%an <%ae>".to_string(),
        "--date=short".to_string(),
    ];

    if let Some(tag) = &last_tag {
        git_args_vec.push(format!("{tag}..HEAD"));
    }

    let git_args: Vec<&str> = git_args_vec
        .iter()
        .map(std::string::String::as_str)
        .collect();

    let log_output = Command::new("git")
        .args(&git_args)
        .current_dir(workspace_path)
        .output()
        .map_err(|e| crate::error::Error::Io(format!("Failed to get git log: {e}")))?;

    let mut changelog = Changelog::new(version.clone());

    for line in String::from_utf8_lossy(&log_output.stdout).lines() {
        let parts: Vec<&str> = line.splitn(3, '|').collect();
        if parts.len() < 3 {
            continue;
        }

        let hash = parts[0].to_string();
        let message = parts[1].to_string();

        // Parse conventional commit
        let parsed = parse_conventional_commit(&message);
        let (commit_type, scope, breaking, short_message) = match parsed {
            Some(p) => p,
            None => continue,
        };

        // Skip excluded types
        if excluded_types().contains(&commit_type.as_str()) {
            continue;
        }

        // Determine section
        let section = ChangelogSection::from_commit_type(&commit_type, breaking);
        let section = match section {
            Some(s) => s,
            None => continue,
        };

        let mut entry = ChangelogEntry::new(section, short_message);
        entry.scope = scope;
        entry.commit_hash = Some(hash.chars().take(7).collect());

        changelog.add_entry(entry);
    }

    Ok(changelog)
}

/// Parse a conventional commit message
///
/// Returns (type, scope, breaking, message)
#[allow(clippy::or_fun_call)]
fn parse_conventional_commit(message: &str) -> Option<(String, Option<String>, bool, String)> {
    let message = message.trim();

    // Check for breaking change indicator
    let breaking = message.contains('!') || message.contains("BREAKING CHANGE:");

    // Parse type and scope
    let colon_pos = message.find(':')?;
    let prefix = &message[..colon_pos];
    let rest = &message[colon_pos + 1..];

    let parts: Vec<&str> = prefix.split(['(', ')', '!']).collect();
    let commit_type = if breaking && prefix.ends_with('!') {
        // Handle "feat!:" format
        prefix[..prefix.len() - 1].to_string()
    } else {
        parts.first()?.to_string()
    };

    let scope = if parts.len() > 2 && prefix.contains('(') {
        Some(parts[1].to_string())
    } else {
        None
    };

    // Clean up message - remove conventional commit prefixes like "feat:", "fix:"
    let short_message = rest
        .trim()
        .strip_prefix("** ")
        .unwrap_or(rest.trim())
        .to_string();

    Some((commit_type, scope, breaking, short_message))
}

/// Format changelog entries as markdown
#[allow(clippy::format_push_string)]
fn format_changelog_entries(changelog: &Changelog) -> String {
    let sections = default_sections();
    let mut output = String::new();

    for section_config in &sections {
        let entries: Vec<_> = changelog
            .entries
            .iter()
            .filter(|e| e.section == section_config.section)
            .collect();

        if entries.is_empty() {
            continue;
        }

        output.push_str(&format!("\n### {}\n\n", section_config.title));

        for entry in entries {
            output.push_str("- ");
            if let Some(scope) = &entry.scope {
                output.push_str(&format!("**{scope}**: "));
            }
            output.push_str(&entry.message);
            if let Some(hash) = &entry.commit_hash {
                output.push_str(&format!(" ({hash})"));
            }
            output.push('\n');
        }
    }

    output
}

/// Update CHANGELOG.md with new version entry
///
/// # Errors
///
/// Returns an error if:
/// - The CHANGELOG.md file cannot be read
/// - The CHANGELOG.md file cannot be written (when not in dry-run mode)
pub fn update_changelog(
    path: &Path,
    version: &str,
    workspace_path: &str,
    dry_run: bool,
) -> Result<bool> {
    let version = SemanticVersion::parse(version)
        .map_err(|e| crate::error::Error::Version(format!("Failed to parse version: {e}")))?;

    let changelog = generate_changelog_from_commits(workspace_path, &version)?;

    // Check if there are any entries
    if changelog.entries.is_empty() {
        // No changelog entries - still create the version header but empty
    }

    let current_content = if path.exists() {
        fs::read_to_string(path)
            .map_err(|e| crate::error::Error::Io(format!("Failed to read CHANGELOG.md: {e}")))?
    } else {
        String::new()
    };

    let date = chrono::Utc::now().format("%Y-%m-%d");
    let formatted_entries = format_changelog_entries(&changelog);

    let new_entry = format!(
        "## [{version}] - {date}{}\n",
        if formatted_entries.is_empty() {
            ""
        } else {
            &formatted_entries
        }
    );

    let updated = if current_content.contains("## [Unreleased]") {
        // Replace Unreleased section with new version
        current_content.replace("## [Unreleased]", new_entry.trim())
    } else if current_content.is_empty() {
        // Create new changelog with header
        format!(
            "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n{}\n",
            new_entry.trim()
        )
    } else {
        // Append to existing changelog (after header, before first version)
        #[allow(clippy::option_if_let_else)]
        let updated = if let Some(pos) = current_content.find("\n## [") {
            format!(
                "{}\n{}\n{}",
                &current_content[..pos],
                new_entry.trim(),
                &current_content[pos..]
            )
        } else {
            format!("{}{}\n", current_content.trim_end(), new_entry.trim())
        };
        updated
    };

    if !dry_run {
        fs::write(path, updated)
            .map_err(|e| crate::error::Error::Io(format!("Failed to write CHANGELOG.md: {e}")))?;
    }

    Ok(true)
}

/// Update CHANGELOG.md with new version entry (legacy signature for compatibility)
///
/// # Errors
///
/// Returns an error if file operations fail
#[allow(dead_code)]
pub fn update_changelog_legacy(path: &Path, version: &str, dry_run: bool) -> Result<bool> {
    let current_content = if path.exists() {
        fs::read_to_string(path)
            .map_err(|e| crate::error::Error::Io(format!("Failed to read CHANGELOG.md: {e}")))?
    } else {
        String::new()
    };

    let date = chrono::Utc::now().format("%Y-%m-%d");
    let new_entry = format!("## [{version}] - {date}\n\n");

    let updated = if current_content.contains("## [Unreleased]") {
        current_content.replace(
            "## [Unreleased]",
            &format!("## [Unreleased]\n\n{}", new_entry.trim()),
        )
    } else if current_content.is_empty() {
        format!("# Changelog\n\n## [Unreleased]\n\n{}\n", new_entry.trim())
    } else {
        format!("{}{}\n", current_content.trim_end(), new_entry)
    };

    if !dry_run {
        fs::write(path, updated)
            .map_err(|e| crate::error::Error::Io(format!("Failed to write CHANGELOG.md: {e}")))?;
    }

    Ok(true)
}

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

    #[test]
    fn test_parse_conventional_commit() {
        let msg = "feat(api): add new endpoint";
        let (t, s, b, m) = parse_conventional_commit(msg).unwrap();
        assert_eq!(t, "feat");
        assert_eq!(s, Some("api".to_string()));
        assert!(!b);
        assert_eq!(m, "add new endpoint");

        let msg = "fix!: critical bug fix";
        let (t, s, b, m) = parse_conventional_commit(msg).unwrap();
        assert_eq!(t, "fix");
        assert!(s.is_none());
        assert!(b);
        assert_eq!(m, "critical bug fix");
    }
}