govctl 0.9.4

Project governance CLI for RFC, ADR, and Work Item management
use std::collections::BTreeMap;

use super::sections::CHANGELOG_HEADER;

pub(super) struct ExistingChangelog {
    pub header: String,
    pub releases: BTreeMap<String, String>,
}

pub(super) fn split_existing_changelog(existing: &str) -> ExistingChangelog {
    let (header, released) = split_header_and_released_sections(existing);
    ExistingChangelog {
        header,
        releases: release_sections_by_version(&released),
    }
}

pub(super) fn contains_version_variant(releases: &BTreeMap<String, String>, version: &str) -> bool {
    version_variants(version)
        .iter()
        .any(|variant| releases.contains_key(variant))
}

pub(super) fn versions_newest_first(releases: &BTreeMap<String, String>) -> Vec<String> {
    let mut versions: Vec<String> = releases.keys().cloned().collect();
    versions.sort_by(|a, b| {
        let a_parts = version_parts(a);
        let b_parts = version_parts(b);
        b_parts.cmp(&a_parts)
    });
    versions
}

fn split_header_and_released_sections(existing: &str) -> (String, String) {
    const UNRELEASED_HEADER: &str = "## [Unreleased]";
    const RELEASE_PATTERN: &str = "\n## [";

    if existing.is_empty() {
        return (CHANGELOG_HEADER.to_string(), String::new());
    }

    if let Some(unreleased_pos) = existing.find(UNRELEASED_HEADER) {
        let header = existing[..unreleased_pos].to_string();
        let after_unreleased = &existing[unreleased_pos + UNRELEASED_HEADER.len()..];
        let released = if let Some(pos) = after_unreleased.find(RELEASE_PATTERN) {
            after_unreleased[pos + 1..].to_string()
        } else {
            String::new()
        };
        return (header, released);
    }

    if let Some(first_release_pos) = existing.find(RELEASE_PATTERN) {
        let header = existing[..first_release_pos + 1].to_string();
        let released = existing[first_release_pos + 1..].to_string();
        return (header, released);
    }

    (existing.to_string(), String::new())
}

fn release_sections_by_version(content: &str) -> BTreeMap<String, String> {
    let mut releases = BTreeMap::new();
    if content.is_empty() {
        return releases;
    }

    let mut current_pos = 0;
    while current_pos < content.len() {
        if !content[current_pos..].starts_with("## [") {
            break;
        }

        let rest = &content[current_pos..];
        let section_end = rest[4..]
            .find("\n## [")
            .map(|position| position + 4 + 1)
            .unwrap_or(rest.len());
        let section = &rest[..section_end];

        let version = if let Some(bracket_end) = section.find(']') {
            section[4..bracket_end].to_string()
        } else {
            current_pos += section.len();
            continue;
        };

        releases.insert(version, section.to_string());
        current_pos += section.len();
    }

    releases
}

fn version_variants(version: &str) -> Vec<String> {
    if let Some(without_prefix) = version.strip_prefix('v') {
        vec![version.to_string(), without_prefix.to_string()]
    } else {
        vec![version.to_string(), format!("v{}", version)]
    }
}

fn version_parts(version: &str) -> Vec<u32> {
    version
        .strip_prefix('v')
        .unwrap_or(version)
        .split('.')
        .filter_map(|part| part.parse().ok())
        .collect()
}